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!(out, " echo \"FAIL [$label]: expected to contain '$expected'\" >&2");
163 let _ = writeln!(out, " return 1");
164 let _ = writeln!(out, " fi");
165 let _ = writeln!(out, "}}");
166 let _ = writeln!(out);
167 let _ = writeln!(out, "assert_not_empty() {{");
168 let _ = writeln!(out, " local actual=\"$1\" label=\"$2\"");
169 let _ = writeln!(out, " if [ -z \"$actual\" ]; then");
170 let _ = writeln!(out, " echo \"FAIL [$label]: expected non-empty value\" >&2");
171 let _ = writeln!(out, " return 1");
172 let _ = writeln!(out, " fi");
173 let _ = writeln!(out, "}}");
174 let _ = writeln!(out);
175 let _ = writeln!(out, "assert_count_min() {{");
176 let _ = writeln!(out, " local count=\"$1\" min=\"$2\" label=\"$3\"");
177 let _ = writeln!(out, " if [ \"$count\" -lt \"$min\" ]; then");
178 let _ = writeln!(
179 out,
180 " echo \"FAIL [$label]: expected at least $min elements, got $count\" >&2"
181 );
182 let _ = writeln!(out, " return 1");
183 let _ = writeln!(out, " fi");
184 let _ = writeln!(out, "}}");
185 let _ = writeln!(out);
186 let _ = writeln!(out, "assert_greater_than() {{");
187 let _ = writeln!(out, " local val=\"$1\" threshold=\"$2\" label=\"$3\"");
188 let _ = writeln!(out, " if [ \"$(echo \"$val > $threshold\" | bc -l)\" != \"1\" ]; then");
189 let _ = writeln!(out, " echo \"FAIL [$label]: expected $val > $threshold\" >&2");
190 let _ = writeln!(out, " return 1");
191 let _ = writeln!(out, " fi");
192 let _ = writeln!(out, "}}");
193 let _ = writeln!(out);
194 let _ = writeln!(out, "assert_greater_than_or_equal() {{");
195 let _ = writeln!(out, " local actual=\"$1\" expected=\"$2\" label=\"$3\"");
196 let _ = writeln!(out, " if [ \"$actual\" -lt \"$expected\" ]; then");
197 let _ = writeln!(out, " echo \"FAIL [$label]: expected $actual >= $expected\" >&2");
198 let _ = writeln!(out, " return 1");
199 let _ = writeln!(out, " fi");
200 let _ = writeln!(out, "}}");
201 let _ = writeln!(out);
202 let _ = writeln!(out, "assert_is_empty() {{");
203 let _ = writeln!(out, " local actual=\"$1\" label=\"$2\"");
204 let _ = writeln!(out, " if [ -n \"$actual\" ]; then");
205 let _ = writeln!(
206 out,
207 " echo \"FAIL [$label]: expected empty value, got '$actual'\" >&2"
208 );
209 let _ = writeln!(out, " return 1");
210 let _ = writeln!(out, " fi");
211 let _ = writeln!(out, "}}");
212 let _ = writeln!(out);
213 let _ = writeln!(out, "assert_less_than() {{");
214 let _ = writeln!(out, " local actual=\"$1\" expected=\"$2\" label=\"$3\"");
215 let _ = writeln!(out, " if [ \"$actual\" -ge \"$expected\" ]; then");
216 let _ = writeln!(out, " echo \"FAIL [$label]: expected $actual < $expected\" >&2");
217 let _ = writeln!(out, " return 1");
218 let _ = writeln!(out, " fi");
219 let _ = writeln!(out, "}}");
220 let _ = writeln!(out);
221 let _ = writeln!(out, "assert_less_than_or_equal() {{");
222 let _ = writeln!(out, " local actual=\"$1\" expected=\"$2\" label=\"$3\"");
223 let _ = writeln!(out, " if [ \"$actual\" -gt \"$expected\" ]; then");
224 let _ = writeln!(out, " echo \"FAIL [$label]: expected $actual <= $expected\" >&2");
225 let _ = writeln!(out, " return 1");
226 let _ = writeln!(out, " fi");
227 let _ = writeln!(out, "}}");
228 let _ = writeln!(out);
229 let _ = writeln!(out, "assert_not_contains() {{");
230 let _ = writeln!(out, " local actual=\"$1\" expected=\"$2\" label=\"$3\"");
231 let _ = writeln!(out, " if [[ \"$actual\" == *\"$expected\"* ]]; then");
232 let _ = writeln!(
233 out,
234 " echo \"FAIL [$label]: expected not to contain '$expected'\" >&2"
235 );
236 let _ = writeln!(out, " return 1");
237 let _ = writeln!(out, " fi");
238 let _ = writeln!(out, "}}");
239 let _ = writeln!(out);
240
241 let script_dir = r#"SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)""#;
243 let _ = writeln!(out, "{script_dir}");
244 let _ = writeln!(out);
245 for category in categories {
246 let _ = writeln!(out, "# shellcheck source=test_{category}.sh");
247 let _ = writeln!(out, "source \"$SCRIPT_DIR/test_{category}.sh\"");
248 }
249 let _ = writeln!(out);
250
251 let _ = writeln!(out, "run_test() {{");
253 let _ = writeln!(out, " local name=\"$1\"");
254 let _ = writeln!(out, " if \"$name\"; then");
255 let _ = writeln!(out, " echo \"PASS: $name\"");
256 let _ = writeln!(out, " PASS=$((PASS + 1))");
257 let _ = writeln!(out, " else");
258 let _ = writeln!(out, " echo \"FAIL: $name\"");
259 let _ = writeln!(out, " FAIL=$((FAIL + 1))");
260 let _ = writeln!(out, " fi");
261 let _ = writeln!(out, "}}");
262 let _ = writeln!(out);
263
264 let _ = writeln!(out, "# Run all generated test functions.");
268 for category in categories {
269 let _ = writeln!(out, "# Category: {category}");
270 let _ = writeln!(out, "run_tests_{category}");
273 }
274 let _ = writeln!(out);
275 let _ = writeln!(out, "echo \"\"");
276 let _ = writeln!(out, "echo \"Results: $PASS passed, $FAIL failed\"");
277 let _ = writeln!(out, "[ \"$FAIL\" -eq 0 ]");
278 out
279}
280
281#[allow(clippy::too_many_arguments)]
283fn render_category_file(
284 category: &str,
285 fixtures: &[&Fixture],
286 binary_name: &str,
287 subcommand: &str,
288 static_cli_args: &[String],
289 cli_flags: &std::collections::HashMap<String, String>,
290 args: &[crate::config::ArgMapping],
291 e2e_config: &E2eConfig,
292) -> String {
293 let safe_category = sanitize_filename(category);
294 let mut out = String::new();
295 let _ = writeln!(out, "#!/usr/bin/env bash");
296 out.push_str(&hash::header(CommentStyle::Hash));
297 let _ = writeln!(out, "# E2e tests for category: {category}");
298 let _ = writeln!(out, "set -euo pipefail");
299 let _ = writeln!(out);
300
301 for fixture in fixtures {
302 render_test_function(
303 &mut out,
304 fixture,
305 binary_name,
306 subcommand,
307 static_cli_args,
308 cli_flags,
309 args,
310 e2e_config,
311 );
312 let _ = writeln!(out);
313 }
314
315 let _ = writeln!(out, "run_tests_{safe_category}() {{");
317 for fixture in fixtures {
318 let fn_name = sanitize_ident(&fixture.id);
319 let _ = writeln!(out, " run_test test_{fn_name}");
320 }
321 let _ = writeln!(out, "}}");
322 out
323}
324
325#[allow(clippy::too_many_arguments)]
327fn render_test_function(
328 out: &mut String,
329 fixture: &Fixture,
330 binary_name: &str,
331 subcommand: &str,
332 static_cli_args: &[String],
333 cli_flags: &std::collections::HashMap<String, String>,
334 _args: &[crate::config::ArgMapping],
335 e2e_config: &E2eConfig,
336) {
337 let fn_name = sanitize_ident(&fixture.id);
338 let description = &fixture.description;
339
340 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
341
342 let _ = writeln!(out, "test_{fn_name}() {{");
343 let _ = writeln!(out, " # {description}");
344
345 let call_config = e2e_config.resolve_call_for_fixture(
347 fixture.call.as_deref(),
348 &fixture.id,
349 &fixture.resolved_category(),
350 &fixture.tags,
351 &fixture.input,
352 );
353 let call_field_resolver = FieldResolver::new(
354 e2e_config.effective_fields(call_config),
355 e2e_config.effective_fields_optional(call_config),
356 e2e_config.effective_result_fields(call_config),
357 e2e_config.effective_fields_array(call_config),
358 &std::collections::HashSet::new(),
359 );
360 let field_resolver = &call_field_resolver;
361
362 let cmd_parts = build_cli_command(
364 fixture,
365 binary_name,
366 subcommand,
367 static_cli_args,
368 cli_flags,
369 &call_config.args,
370 );
371
372 if expects_error {
373 let cmd = cmd_parts.join(" ");
374 let _ = writeln!(out, " if {cmd} >/dev/null 2>&1; then");
375 let _ = writeln!(
376 out,
377 " echo 'FAIL [error]: expected command to fail but it succeeded' >&2"
378 );
379 let _ = writeln!(out, " return 1");
380 let _ = writeln!(out, " fi");
381 let _ = writeln!(out, "}}");
382 return;
383 }
384
385 let has_active_assertions = fixture.assertions.iter().any(|a| {
387 a.field
388 .as_ref()
389 .is_none_or(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
390 });
391
392 let cmd = cmd_parts.join(" ");
394 if has_active_assertions {
395 let _ = writeln!(out, " local output");
396 let _ = writeln!(out, " output=$({cmd})");
397 } else {
398 let _ = writeln!(out, " {cmd} >/dev/null");
399 }
400 let _ = writeln!(out);
401
402 for assertion in &fixture.assertions {
404 render_assertion(out, assertion, binary_name, field_resolver);
405 }
406
407 let _ = writeln!(out, "}}");
408}
409
410fn build_cli_command(
415 fixture: &Fixture,
416 binary_name: &str,
417 subcommand: &str,
418 static_cli_args: &[String],
419 cli_flags: &std::collections::HashMap<String, String>,
420 args: &[crate::config::ArgMapping],
421) -> Vec<String> {
422 let mut parts: Vec<String> = vec![binary_name.to_string(), subcommand.to_string()];
423
424 for arg in args {
425 match arg.arg_type.as_str() {
426 "mock_url" => {
427 let upper_id = fixture.id.to_uppercase();
434 parts.push(format!(
435 "\"${{MOCK_SERVER_{upper_id}:-${{MOCK_SERVER_URL}}/fixtures/{}}}\"",
436 fixture.id
437 ));
438 }
439 "handle" => {
440 }
442 _ => {
443 if let Some(flag) = cli_flags.get(&arg.field) {
445 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
446 if let Some(val) = fixture.input.get(field) {
447 if !val.is_null() {
448 let val_str = json_value_to_shell_arg(val);
449 parts.push(flag.clone());
450 parts.push(val_str);
451 }
452 }
453 }
454 }
455 }
456 }
457
458 if let Some(config_val) = fixture.input.get("config") {
460 if !config_val.is_null() {
461 let config_json = serde_json::to_string(config_val).unwrap_or_default();
463 parts.push("--config".to_string());
464 parts.push(format!("'{}'", escape_shell(&config_json)));
465 }
466 }
467
468 for static_arg in static_cli_args {
470 parts.push(static_arg.clone());
471 }
472
473 parts
474}
475
476fn json_value_to_shell_arg(value: &serde_json::Value) -> String {
481 match value {
482 serde_json::Value::String(s) => format!("'{}'", escape_shell(s)),
483 serde_json::Value::Bool(b) => b.to_string(),
484 serde_json::Value::Number(n) => n.to_string(),
485 serde_json::Value::Null => "''".to_string(),
486 other => format!("'{}'", escape_shell(&other.to_string())),
487 }
488}
489
490fn field_to_jq_path(resolved: &str) -> String {
497 if let Some((prefix, suffix)) = resolved.rsplit_once('.') {
500 if suffix == "length" || suffix == "count" || suffix == "size" {
501 return format!(".{prefix} | length");
502 }
503 }
504 if resolved == "length" || resolved == "count" || resolved == "size" {
506 return ". | length".to_string();
507 }
508 format!(".{resolved}")
509}
510
511fn build_brew_method_call(binary_name: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
518 let subcommand = method_name.replace('_', "-");
519 if let Some(args_val) = args {
520 let arg_str = args_val
521 .as_object()
522 .map(|obj| {
523 obj.values()
524 .map(|v| match v {
525 serde_json::Value::String(s) => format!("'{}'", escape_shell(s)),
526 other => other.to_string(),
527 })
528 .collect::<Vec<_>>()
529 .join(" ")
530 })
531 .unwrap_or_default();
532 if arg_str.is_empty() {
533 format!("{binary_name} {subcommand} \"$output\"")
534 } else {
535 format!("{binary_name} {subcommand} \"$output\" {arg_str}")
536 }
537 } else {
538 format!("{binary_name} {subcommand} \"$output\"")
539 }
540}
541
542fn render_assertion(out: &mut String, assertion: &Assertion, binary_name: &str, field_resolver: &FieldResolver) {
544 if let Some(f) = &assertion.field {
546 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
547 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
548 return;
549 }
550 }
551
552 match assertion.assertion_type.as_str() {
553 "equals" => {
554 if let Some(field) = &assertion.field {
555 if let Some(expected) = &assertion.value {
556 let resolved = field_resolver.resolve(field);
557 let jq_path = field_to_jq_path(resolved);
558 let expected_str = json_value_to_shell_string(expected);
559 let safe_field = sanitize_ident(field);
560 let _ = writeln!(out, " local val_{safe_field}");
561 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
562 let _ = writeln!(out, " assert_equals \"$val_{safe_field}\" '{expected_str}' '{field}'");
563 }
564 }
565 }
566 "contains" => {
567 if let Some(field) = &assertion.field {
568 if let Some(expected) = &assertion.value {
569 let resolved = field_resolver.resolve(field);
570 let jq_path = field_to_jq_path(resolved);
571 let expected_str = json_value_to_shell_string(expected);
572 let safe_field = sanitize_ident(field);
573 let _ = writeln!(out, " local val_{safe_field}");
574 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
575 let _ = writeln!(
576 out,
577 " assert_contains \"$val_{safe_field}\" '{expected_str}' '{field}'"
578 );
579 }
580 }
581 }
582 "not_empty" | "tree_not_null" => {
583 if let Some(field) = &assertion.field {
584 let resolved = field_resolver.resolve(field);
585 let jq_path = field_to_jq_path(resolved);
586 let safe_field = sanitize_ident(field);
587 let _ = writeln!(out, " local val_{safe_field}");
588 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
589 let _ = writeln!(out, " assert_not_empty \"$val_{safe_field}\" '{field}'");
590 }
591 }
592 "count_min" | "root_child_count_min" => {
593 if let Some(field) = &assertion.field {
594 if let Some(val) = &assertion.value {
595 if let Some(min) = val.as_u64() {
596 let resolved = field_resolver.resolve(field);
597 let jq_path = field_to_jq_path(resolved);
598 let safe_field = sanitize_ident(field);
599 let _ = writeln!(out, " local count_{safe_field}");
600 let _ = writeln!(
601 out,
602 " count_{safe_field}=$(echo \"$output\" | jq '{jq_path} | length')"
603 );
604 let _ = writeln!(out, " assert_count_min \"$count_{safe_field}\" {min} '{field}'");
605 }
606 }
607 }
608 }
609 "greater_than" => {
610 if let Some(field) = &assertion.field {
611 if let Some(val) = &assertion.value {
612 let resolved = field_resolver.resolve(field);
613 let jq_path = field_to_jq_path(resolved);
614 let threshold = json_value_to_shell_string(val);
615 let safe_field = sanitize_ident(field);
616 let _ = writeln!(out, " local val_{safe_field}");
617 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
618 let _ = writeln!(
619 out,
620 " assert_greater_than \"$val_{safe_field}\" '{threshold}' '{field}'"
621 );
622 }
623 }
624 }
625 "greater_than_or_equal" => {
626 if let Some(field) = &assertion.field {
627 if let Some(val) = &assertion.value {
628 let resolved = field_resolver.resolve(field);
629 let jq_path = field_to_jq_path(resolved);
630 let threshold = json_value_to_shell_string(val);
631 let safe_field = sanitize_ident(field);
632 let _ = writeln!(out, " local val_{safe_field}");
633 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
634 let _ = writeln!(
635 out,
636 " assert_greater_than_or_equal \"$val_{safe_field}\" '{threshold}' '{field}'"
637 );
638 }
639 }
640 }
641 "contains_all" => {
642 if let Some(field) = &assertion.field {
643 if let Some(serde_json::Value::Array(items)) = &assertion.value {
644 let resolved = field_resolver.resolve(field);
645 let jq_path = field_to_jq_path(resolved);
646 let safe_field = sanitize_ident(field);
647 let _ = writeln!(out, " local val_{safe_field}");
648 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
649 for (index, item) in items.iter().enumerate() {
650 let item_str = json_value_to_shell_string(item);
651 let _ = writeln!(
652 out,
653 " assert_contains \"$val_{safe_field}\" '{item_str}' '{field}[{index}]'"
654 );
655 }
656 }
657 }
658 }
659 "is_empty" => {
660 if let Some(field) = &assertion.field {
661 let resolved = field_resolver.resolve(field);
662 let jq_path = field_to_jq_path(resolved);
663 let safe_field = sanitize_ident(field);
664 let _ = writeln!(out, " local val_{safe_field}");
665 let _ = writeln!(
667 out,
668 " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path} // empty')"
669 );
670 let _ = writeln!(out, " assert_is_empty \"$val_{safe_field}\" '{field}'");
671 }
672 }
673 "less_than" => {
674 if let Some(field) = &assertion.field {
675 if let Some(val) = &assertion.value {
676 let resolved = field_resolver.resolve(field);
677 let jq_path = field_to_jq_path(resolved);
678 let threshold = json_value_to_shell_string(val);
679 let safe_field = sanitize_ident(field);
680 let _ = writeln!(out, " local val_{safe_field}");
681 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
682 let _ = writeln!(out, " assert_less_than \"$val_{safe_field}\" '{threshold}' '{field}'");
683 }
684 }
685 }
686 "not_contains" => {
687 if let Some(field) = &assertion.field {
688 if let Some(expected) = &assertion.value {
689 let resolved = field_resolver.resolve(field);
690 let jq_path = field_to_jq_path(resolved);
691 let expected_str = json_value_to_shell_string(expected);
692 let safe_field = sanitize_ident(field);
693 let _ = writeln!(out, " local val_{safe_field}");
694 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
695 let _ = writeln!(
696 out,
697 " assert_not_contains \"$val_{safe_field}\" '{expected_str}' '{field}'"
698 );
699 }
700 }
701 }
702 "count_equals" => {
703 if let Some(field) = &assertion.field {
704 if let Some(val) = &assertion.value {
705 if let Some(n) = val.as_u64() {
706 let resolved = field_resolver.resolve(field);
707 let jq_path = field_to_jq_path(resolved);
708 let safe_field = sanitize_ident(field);
709 let _ = writeln!(out, " local count_{safe_field}");
710 let _ = writeln!(
711 out,
712 " count_{safe_field}=$(echo \"$output\" | jq '{jq_path} | length')"
713 );
714 let _ = writeln!(out, " [ \"$count_{safe_field}\" -eq {n} ] || exit 1");
715 }
716 }
717 }
718 }
719 "is_true" => {
720 if let Some(field) = &assertion.field {
721 let resolved = field_resolver.resolve(field);
722 let jq_path = field_to_jq_path(resolved);
723 let safe_field = sanitize_ident(field);
724 let _ = writeln!(out, " local val_{safe_field}");
725 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
726 let _ = writeln!(out, " [ \"$val_{safe_field}\" = \"true\" ] || exit 1");
727 }
728 }
729 "is_false" => {
730 if let Some(field) = &assertion.field {
731 let resolved = field_resolver.resolve(field);
732 let jq_path = field_to_jq_path(resolved);
733 let safe_field = sanitize_ident(field);
734 let _ = writeln!(out, " local val_{safe_field}");
735 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
736 let _ = writeln!(out, " [ \"$val_{safe_field}\" = \"false\" ] || exit 1");
737 }
738 }
739 "less_than_or_equal" => {
740 if let Some(field) = &assertion.field {
741 if let Some(val) = &assertion.value {
742 let resolved = field_resolver.resolve(field);
743 let jq_path = field_to_jq_path(resolved);
744 let threshold = json_value_to_shell_string(val);
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!(
749 out,
750 " assert_less_than_or_equal \"$val_{safe_field}\" '{threshold}' '{field}'"
751 );
752 }
753 }
754 }
755 "method_result" => {
756 if let Some(method_name) = &assertion.method {
757 let check = assertion.check.as_deref().unwrap_or("is_true");
758 let cmd = build_brew_method_call(binary_name, method_name, assertion.args.as_ref());
759 if check == "is_error" {
762 let _ = writeln!(out, " if {cmd} >/dev/null 2>&1; then");
763 let _ = writeln!(
764 out,
765 " echo 'FAIL [method_result]: expected method to raise error but it succeeded' >&2"
766 );
767 let _ = writeln!(out, " return 1");
768 let _ = writeln!(out, " fi");
769 } else {
770 let method_var = format!("method_result_{}", sanitize_ident(method_name));
771 let _ = writeln!(out, " local {method_var}");
772 let _ = writeln!(out, " {method_var}=$({cmd})");
773 match check {
774 "equals" => {
775 if let Some(val) = &assertion.value {
776 let expected = json_value_to_shell_string(val);
777 let _ = writeln!(out, " [ \"${method_var}\" = '{expected}' ] || exit 1");
778 }
779 }
780 "is_true" => {
781 let _ = writeln!(out, " [ \"${method_var}\" = \"true\" ] || exit 1");
782 }
783 "is_false" => {
784 let _ = writeln!(out, " [ \"${method_var}\" = \"false\" ] || exit 1");
785 }
786 "greater_than_or_equal" => {
787 if let Some(val) = &assertion.value {
788 if let Some(n) = val.as_u64() {
789 let _ = writeln!(out, " [ \"${method_var}\" -ge {n} ] || exit 1");
790 }
791 }
792 }
793 "count_min" => {
794 if let Some(val) = &assertion.value {
795 if let Some(n) = val.as_u64() {
796 let _ = writeln!(
797 out,
798 " local count_from_method_result=$(echo \"${method_var}\" | jq 'length')"
799 );
800 let _ = writeln!(out, " [ \"$count_from_method_result\" -ge {n} ] || exit 1");
801 }
802 }
803 }
804 "contains" => {
805 if let Some(val) = &assertion.value {
806 let expected = json_value_to_shell_string(val);
807 let _ = writeln!(out, " [[ \"${method_var}\" == *'{expected}'* ]] || exit 1");
808 }
809 }
810 other_check => {
811 panic!("Brew e2e generator: unsupported method_result check type: {other_check}");
812 }
813 }
814 }
815 } else {
816 panic!("method_result assertion missing 'method' field");
817 }
818 }
819 "min_length" => {
820 if let Some(field) = &assertion.field {
821 if let Some(val) = &assertion.value {
822 if let Some(n) = val.as_u64() {
823 let resolved = field_resolver.resolve(field);
824 let jq_path = field_to_jq_path(resolved);
825 let safe_field = sanitize_ident(field);
826 let _ = writeln!(out, " local val_{safe_field}");
827 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
828 let _ = writeln!(
829 out,
830 " [ \"${{#val_{safe_field}}}\" -ge {n} ] || {{ echo \"FAIL [{field}]: expected length >= {n}\" >&2; return 1; }}"
831 );
832 }
833 }
834 }
835 }
836 "max_length" => {
837 if let Some(field) = &assertion.field {
838 if let Some(val) = &assertion.value {
839 if let Some(n) = val.as_u64() {
840 let resolved = field_resolver.resolve(field);
841 let jq_path = field_to_jq_path(resolved);
842 let safe_field = sanitize_ident(field);
843 let _ = writeln!(out, " local val_{safe_field}");
844 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
845 let _ = writeln!(
846 out,
847 " [ \"${{#val_{safe_field}}}\" -le {n} ] || {{ echo \"FAIL [{field}]: expected length <= {n}\" >&2; return 1; }}"
848 );
849 }
850 }
851 }
852 }
853 "ends_with" => {
854 if let Some(field) = &assertion.field {
855 if let Some(expected) = &assertion.value {
856 let resolved = field_resolver.resolve(field);
857 let jq_path = field_to_jq_path(resolved);
858 let expected_str = json_value_to_shell_string(expected);
859 let safe_field = sanitize_ident(field);
860 let _ = writeln!(out, " local val_{safe_field}");
861 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
862 let _ = writeln!(
863 out,
864 " [[ \"$val_{safe_field}\" == *'{expected_str}' ]] || {{ echo \"FAIL [{field}]: expected to end with '{expected_str}'\" >&2; return 1; }}"
865 );
866 }
867 }
868 }
869 "matches_regex" => {
870 if let Some(field) = &assertion.field {
871 if let Some(expected) = &assertion.value {
872 if let Some(pattern) = expected.as_str() {
873 let resolved = field_resolver.resolve(field);
874 let jq_path = field_to_jq_path(resolved);
875 let safe_field = sanitize_ident(field);
876 let _ = writeln!(out, " local val_{safe_field}");
877 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
878 let _ = writeln!(
879 out,
880 " [[ \"$val_{safe_field}\" =~ {pattern} ]] || {{ echo \"FAIL [{field}]: expected to match /{pattern}/\" >&2; return 1; }}"
881 );
882 }
883 }
884 }
885 }
886 "not_error" => {
887 }
889 "error" => {
890 }
892 other => {
893 panic!("Brew e2e generator: unsupported assertion type: {other}");
894 }
895 }
896}
897
898fn json_value_to_shell_string(value: &serde_json::Value) -> String {
902 match value {
903 serde_json::Value::String(s) => escape_shell(s),
904 serde_json::Value::Bool(b) => b.to_string(),
905 serde_json::Value::Number(n) => n.to_string(),
906 serde_json::Value::Null => String::new(),
907 other => escape_shell(&other.to_string()),
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 fn assert_shfmt_canonical_indent(script: &str, context: &str) {
921 for (lineno, line) in script.lines().enumerate() {
922 let leading_spaces = line.chars().take_while(|c| *c == ' ').count();
923 assert!(
924 leading_spaces.is_multiple_of(2),
925 "{context}: line {lineno} has {leading_spaces}-space indent (must be a multiple of 2 for shfmt compatibility): {line:?}",
926 );
927 }
928 }
929
930 #[test]
931 fn render_run_tests_uses_two_space_indent() {
932 let categories = vec!["auth".to_string(), "crawl".to_string()];
933 let script = render_run_tests(&categories);
934 assert_shfmt_canonical_indent(&script, "render_run_tests");
935 assert!(
936 script.lines().any(|l| l.starts_with(" ") && !l.starts_with(" ")),
937 "render_run_tests should emit at least one 2-space-indented line; got:\n{script}",
938 );
939 }
940}