Skip to main content

alef_e2e/codegen/
brew.rs

1//! Brew (Homebrew CLI) e2e test generator.
2//!
3//! Generates a self-contained shell-script test suite that tests a CLI binary
4//! installed via Homebrew.  The suite consists of:
5//!
6//! - `run_tests.sh` — main runner that sources per-category files, tracks
7//!   pass/fail counts and exits 1 on any failure.
8//! - `test_{category}.sh` — one file per fixture category, each containing
9//!   a `test_{fixture_id}()` shell function.
10//!
11//! Each test function:
12//! 1. Constructs a CLI invocation: `{binary} {subcommand} "{url}" {flags...}`
13//! 2. Captures stdout into a variable.
14//! 3. Uses `jq` to extract fields and runs helper assertion functions.
15//!
16//! Requirements at runtime: `bash`, `jq`, and `MOCK_SERVER_URL` env var.
17
18use 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
31/// Brew (Homebrew CLI) e2e code generator.
32pub 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        // Resolve call config with overrides for the "brew" language key.
47        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        // Static CLI flags appended to every invocation.
55        let static_cli_args: Vec<String> = overrides.map(|o| o.cli_args.clone()).unwrap_or_default();
56
57        // Field-to-flag mapping (fixture input field → CLI flag name).
58        let cli_flags: std::collections::HashMap<String, String> =
59            overrides.map(|o| o.cli_flags.clone()).unwrap_or_default();
60
61        // Resolve binary name from the "brew" package entry, falling back to call.module.
62        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        // Filter active groups (non-skipped fixtures).
72        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        // Generate run_tests.sh.
87        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        // Generate per-category test files.
98        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
126/// Render the main `run_tests.sh` runner script.
127fn 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    // Helper functions.
148    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    // Source per-category files.
242    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    // Run each test function and track pass/fail.
252    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    // Gather all test function names from category files then call them.
265    // We enumerate them at code-generation time so the runner doesn't need
266    // introspection at runtime.
267    let _ = writeln!(out, "# Run all generated test functions.");
268    for category in categories {
269        let _ = writeln!(out, "# Category: {category}");
270        // We emit a placeholder comment — the actual list is per-category.
271        // The run_test calls are emitted inline below based on known IDs.
272        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/// Render a per-category `test_{category}.sh` file.
282#[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    // Emit a runner function for this category.
316    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/// Render a single `test_{id}()` function for a fixture.
326#[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    // Resolve fixture-specific call config if provided, otherwise use defaults.
346    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    // Build the CLI command using the resolved call config.
363    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    // Check if any assertion will actually emit code (not be skipped).
386    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    // Capture output (only if there are active assertions that reference it).
393    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    // Emit assertions.
403    for assertion in &fixture.assertions {
404        render_assertion(out, assertion, binary_name, field_resolver);
405    }
406
407    let _ = writeln!(out, "}}");
408}
409
410/// Build the shell CLI invocation as a list of tokens.
411///
412/// Tokens are returned unquoted where safe (flag names) or single-quoted
413/// (string values from the fixture).
414fn 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                // Positional URL argument.
428                //
429                // Prefer the per-fixture `MOCK_SERVER_<FIXTURE_ID>` env var when set —
430                // host-root fixtures (robots.txt, sitemap.xml) need their own listener
431                // so the path lives at `/robots.txt`, not `/fixtures/<id>/robots.txt`.
432                // Fall back to `MOCK_SERVER_URL/fixtures/<id>` for the common case.
433                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                // CLI manages its own engine; skip handle args.
441            }
442            _ => {
443                // Check if there is a cli_flags mapping for this field.
444                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    // Check if fixture has input.config and emit it as --config flag.
459    if let Some(config_val) = fixture.input.get("config") {
460        if !config_val.is_null() {
461            // Minify the JSON config object to a single line for shell argument.
462            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    // Append static CLI args last.
469    for static_arg in static_cli_args {
470        parts.push(static_arg.clone());
471    }
472
473    parts
474}
475
476/// Convert a JSON value to a shell argument string.
477///
478/// Strings are wrapped in single quotes with embedded single quotes escaped.
479/// Numbers and booleans are emitted verbatim.
480fn 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
490/// Convert a fixture field path to a jq expression.
491///
492/// A path like `metadata.title` becomes `.metadata.title`.
493/// An array field like `links` becomes `.links`.
494/// The pseudo-property `length` (also `count`, `size`) becomes `| length`
495/// because jq uses pipe syntax for the `length` builtin.
496fn field_to_jq_path(resolved: &str) -> String {
497    // Check if the path ends with a length/count/size pseudo-property.
498    // E.g., "pages.length" → ".pages | length"
499    if let Some((prefix, suffix)) = resolved.rsplit_once('.') {
500        if suffix == "length" || suffix == "count" || suffix == "size" {
501            return format!(".{prefix} | length");
502        }
503    }
504    // Handle bare "length" / "count" / "size" (top-level array).
505    if resolved == "length" || resolved == "count" || resolved == "size" {
506        return ". | length".to_string();
507    }
508    format!(".{resolved}")
509}
510
511/// Build a CLI command for a method_result assertion.
512///
513/// Uses generic dispatch: `{binary_name} {kebab-method} "$output" args...`.
514/// The method name is converted from snake_case to kebab-case for the CLI subcommand.
515/// Args from the fixture JSON object are emitted as positional shell arguments in
516/// insertion order, using best-effort shell quoting.
517fn 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
542/// Render a single assertion as shell code.
543fn render_assertion(out: &mut String, assertion: &Assertion, binary_name: &str, field_resolver: &FieldResolver) {
544    // Skip assertions on fields not available on the result type.
545    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                // Use `// empty` so JSON null becomes an empty string rather than the literal "null".
666                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                // For is_error, skip capturing the result — just run the command and check
760                // the exit code so we don't execute the method twice.
761                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            // No-op: reaching this point means the call succeeded.
888        }
889        "error" => {
890            // Handled at the function level (early return above).
891        }
892        other => {
893            panic!("Brew e2e generator: unsupported assertion type: {other}");
894        }
895    }
896}
897
898/// Convert a JSON value to a plain string suitable for use in shell assertions.
899///
900/// Returns the bare string content (no quotes) — callers wrap in single quotes.
901fn 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    /// Every leading-whitespace prefix in an emitted shell line must be a
916    /// multiple of 2 spaces. shfmt's default indent step (and the `shfmt -i 2`
917    /// pre-commit hook used downstream by `kreuzcrawl`) rewrites any other
918    /// indent step, which then causes the alef-emitted scripts to be rewritten
919    /// by pre-commit hooks on every consumer run.
920    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}