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    ) -> Result<Vec<GeneratedFile>> {
41        let lang = self.language_name();
42        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
43
44        // Resolve call config with overrides for the "brew" language key.
45        let call = &e2e_config.call;
46        let overrides = call.overrides.get(lang);
47        let subcommand = overrides
48            .and_then(|o| o.function.as_ref())
49            .cloned()
50            .unwrap_or_else(|| call.function.clone());
51
52        // Static CLI flags appended to every invocation.
53        let static_cli_args: Vec<String> = overrides.map(|o| o.cli_args.clone()).unwrap_or_default();
54
55        // Field-to-flag mapping (fixture input field → CLI flag name).
56        let cli_flags: std::collections::HashMap<String, String> =
57            overrides.map(|o| o.cli_flags.clone()).unwrap_or_default();
58
59        // Resolve binary name from the "brew" package entry, falling back to call.module.
60        let binary_name = e2e_config
61            .registry
62            .packages
63            .get(lang)
64            .and_then(|p| p.name.as_ref())
65            .cloned()
66            .or_else(|| e2e_config.packages.get(lang).and_then(|p| p.name.as_ref()).cloned())
67            .unwrap_or_else(|| call.module.clone());
68
69        // Filter active groups (non-skipped fixtures).
70        let active_groups: Vec<(&FixtureGroup, Vec<&Fixture>)> = groups
71            .iter()
72            .filter_map(|group| {
73                let active: Vec<&Fixture> = group
74                    .fixtures
75                    .iter()
76                    .filter(|f| super::should_include_fixture(f, lang, e2e_config))
77                    .collect();
78                if active.is_empty() { None } else { Some((group, active)) }
79            })
80            .collect();
81
82        let field_resolver = FieldResolver::new(
83            &e2e_config.fields,
84            &e2e_config.fields_optional,
85            &e2e_config.result_fields,
86            &e2e_config.fields_array,
87            &std::collections::HashSet::new(),
88        );
89
90        let mut files = Vec::new();
91
92        // Generate run_tests.sh.
93        let category_names: Vec<String> = active_groups
94            .iter()
95            .map(|(g, _)| sanitize_filename(&g.category))
96            .collect();
97        files.push(GeneratedFile {
98            path: output_base.join("run_tests.sh"),
99            content: render_run_tests(&category_names),
100            generated_header: true,
101        });
102
103        // Generate per-category test files.
104        for (group, active) in &active_groups {
105            let safe_category = sanitize_filename(&group.category);
106            let filename = format!("test_{safe_category}.sh");
107            let content = render_category_file(
108                &group.category,
109                active,
110                &binary_name,
111                &subcommand,
112                &static_cli_args,
113                &cli_flags,
114                &e2e_config.call.args,
115                &field_resolver,
116                e2e_config,
117            );
118            files.push(GeneratedFile {
119                path: output_base.join(filename),
120                content,
121                generated_header: true,
122            });
123        }
124
125        Ok(files)
126    }
127
128    fn language_name(&self) -> &'static str {
129        "brew"
130    }
131}
132
133/// Render the main `run_tests.sh` runner script.
134fn render_run_tests(categories: &[String]) -> String {
135    let mut out = String::new();
136    let _ = writeln!(out, "#!/usr/bin/env bash");
137    out.push_str(&hash::header(CommentStyle::Hash));
138    let _ = writeln!(out, "# shellcheck disable=SC1091");
139    let _ = writeln!(out, "set -euo pipefail");
140    let _ = writeln!(out);
141    let _ = writeln!(out, "# MOCK_SERVER_URL must be set to the base URL of the mock server.");
142    let _ = writeln!(out, ": \"${{MOCK_SERVER_URL:?MOCK_SERVER_URL is required}}\"");
143    let _ = writeln!(out);
144    let _ = writeln!(out, "# Verify that jq is available.");
145    let _ = writeln!(out, "if ! command -v jq &>/dev/null; then");
146    let _ = writeln!(out, "    echo 'error: jq is required but not found in PATH' >&2");
147    let _ = writeln!(out, "    exit 1");
148    let _ = writeln!(out, "fi");
149    let _ = writeln!(out);
150    let _ = writeln!(out, "PASS=0");
151    let _ = writeln!(out, "FAIL=0");
152    let _ = writeln!(out);
153
154    // Helper functions.
155    let _ = writeln!(out, "assert_equals() {{");
156    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
157    let _ = writeln!(out, "    if [ \"$actual\" != \"$expected\" ]; then");
158    let _ = writeln!(
159        out,
160        "        echo \"FAIL [$label]: expected '$expected', got '$actual'\" >&2"
161    );
162    let _ = writeln!(out, "        return 1");
163    let _ = writeln!(out, "    fi");
164    let _ = writeln!(out, "}}");
165    let _ = writeln!(out);
166    let _ = writeln!(out, "assert_contains() {{");
167    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
168    let _ = writeln!(out, "    if [[ \"$actual\" != *\"$expected\"* ]]; then");
169    let _ = writeln!(
170        out,
171        "        echo \"FAIL [$label]: expected to contain '$expected'\" >&2"
172    );
173    let _ = writeln!(out, "        return 1");
174    let _ = writeln!(out, "    fi");
175    let _ = writeln!(out, "}}");
176    let _ = writeln!(out);
177    let _ = writeln!(out, "assert_not_empty() {{");
178    let _ = writeln!(out, "    local actual=\"$1\" label=\"$2\"");
179    let _ = writeln!(out, "    if [ -z \"$actual\" ]; then");
180    let _ = writeln!(out, "        echo \"FAIL [$label]: expected non-empty value\" >&2");
181    let _ = writeln!(out, "        return 1");
182    let _ = writeln!(out, "    fi");
183    let _ = writeln!(out, "}}");
184    let _ = writeln!(out);
185    let _ = writeln!(out, "assert_count_min() {{");
186    let _ = writeln!(out, "    local count=\"$1\" min=\"$2\" label=\"$3\"");
187    let _ = writeln!(out, "    if [ \"$count\" -lt \"$min\" ]; then");
188    let _ = writeln!(
189        out,
190        "        echo \"FAIL [$label]: expected at least $min elements, got $count\" >&2"
191    );
192    let _ = writeln!(out, "        return 1");
193    let _ = writeln!(out, "    fi");
194    let _ = writeln!(out, "}}");
195    let _ = writeln!(out);
196    let _ = writeln!(out, "assert_greater_than() {{");
197    let _ = writeln!(out, "    local val=\"$1\" threshold=\"$2\" label=\"$3\"");
198    let _ = writeln!(
199        out,
200        "    if [ \"$(echo \"$val > $threshold\" | bc -l)\" != \"1\" ]; then"
201    );
202    let _ = writeln!(out, "        echo \"FAIL [$label]: expected $val > $threshold\" >&2");
203    let _ = writeln!(out, "        return 1");
204    let _ = writeln!(out, "    fi");
205    let _ = writeln!(out, "}}");
206    let _ = writeln!(out);
207    let _ = writeln!(out, "assert_greater_than_or_equal() {{");
208    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
209    let _ = writeln!(out, "    if [ \"$actual\" -lt \"$expected\" ]; then");
210    let _ = writeln!(out, "        echo \"FAIL [$label]: expected $actual >= $expected\" >&2");
211    let _ = writeln!(out, "        return 1");
212    let _ = writeln!(out, "    fi");
213    let _ = writeln!(out, "}}");
214    let _ = writeln!(out);
215    let _ = writeln!(out, "assert_is_empty() {{");
216    let _ = writeln!(out, "    local actual=\"$1\" label=\"$2\"");
217    let _ = writeln!(out, "    if [ -n \"$actual\" ]; then");
218    let _ = writeln!(
219        out,
220        "        echo \"FAIL [$label]: expected empty value, got '$actual'\" >&2"
221    );
222    let _ = writeln!(out, "        return 1");
223    let _ = writeln!(out, "    fi");
224    let _ = writeln!(out, "}}");
225    let _ = writeln!(out);
226    let _ = writeln!(out, "assert_less_than() {{");
227    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
228    let _ = writeln!(out, "    if [ \"$actual\" -ge \"$expected\" ]; then");
229    let _ = writeln!(out, "        echo \"FAIL [$label]: expected $actual < $expected\" >&2");
230    let _ = writeln!(out, "        return 1");
231    let _ = writeln!(out, "    fi");
232    let _ = writeln!(out, "}}");
233    let _ = writeln!(out);
234    let _ = writeln!(out, "assert_less_than_or_equal() {{");
235    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
236    let _ = writeln!(out, "    if [ \"$actual\" -gt \"$expected\" ]; then");
237    let _ = writeln!(out, "        echo \"FAIL [$label]: expected $actual <= $expected\" >&2");
238    let _ = writeln!(out, "        return 1");
239    let _ = writeln!(out, "    fi");
240    let _ = writeln!(out, "}}");
241    let _ = writeln!(out);
242    let _ = writeln!(out, "assert_not_contains() {{");
243    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
244    let _ = writeln!(out, "    if [[ \"$actual\" == *\"$expected\"* ]]; then");
245    let _ = writeln!(
246        out,
247        "        echo \"FAIL [$label]: expected not to contain '$expected'\" >&2"
248    );
249    let _ = writeln!(out, "        return 1");
250    let _ = writeln!(out, "    fi");
251    let _ = writeln!(out, "}}");
252    let _ = writeln!(out);
253
254    // Source per-category files.
255    let script_dir = r#"SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)""#;
256    let _ = writeln!(out, "{script_dir}");
257    let _ = writeln!(out);
258    for category in categories {
259        let _ = writeln!(out, "# shellcheck source=test_{category}.sh");
260        let _ = writeln!(out, "source \"$SCRIPT_DIR/test_{category}.sh\"");
261    }
262    let _ = writeln!(out);
263
264    // Run each test function and track pass/fail.
265    let _ = writeln!(out, "run_test() {{");
266    let _ = writeln!(out, "    local name=\"$1\"");
267    let _ = writeln!(out, "    if \"$name\"; then");
268    let _ = writeln!(out, "        echo \"PASS: $name\"");
269    let _ = writeln!(out, "        PASS=$((PASS + 1))");
270    let _ = writeln!(out, "    else");
271    let _ = writeln!(out, "        echo \"FAIL: $name\"");
272    let _ = writeln!(out, "        FAIL=$((FAIL + 1))");
273    let _ = writeln!(out, "    fi");
274    let _ = writeln!(out, "}}");
275    let _ = writeln!(out);
276
277    // Gather all test function names from category files then call them.
278    // We enumerate them at code-generation time so the runner doesn't need
279    // introspection at runtime.
280    let _ = writeln!(out, "# Run all generated test functions.");
281    for category in categories {
282        let _ = writeln!(out, "# Category: {category}");
283        // We emit a placeholder comment — the actual list is per-category.
284        // The run_test calls are emitted inline below based on known IDs.
285        let _ = writeln!(out, "run_tests_{category}");
286    }
287    let _ = writeln!(out);
288    let _ = writeln!(out, "echo \"\"");
289    let _ = writeln!(out, "echo \"Results: $PASS passed, $FAIL failed\"");
290    let _ = writeln!(out, "[ \"$FAIL\" -eq 0 ]");
291    out
292}
293
294/// Render a per-category `test_{category}.sh` file.
295#[allow(clippy::too_many_arguments)]
296fn render_category_file(
297    category: &str,
298    fixtures: &[&Fixture],
299    binary_name: &str,
300    subcommand: &str,
301    static_cli_args: &[String],
302    cli_flags: &std::collections::HashMap<String, String>,
303    args: &[crate::config::ArgMapping],
304    field_resolver: &FieldResolver,
305    e2e_config: &E2eConfig,
306) -> String {
307    let safe_category = sanitize_filename(category);
308    let mut out = String::new();
309    let _ = writeln!(out, "#!/usr/bin/env bash");
310    out.push_str(&hash::header(CommentStyle::Hash));
311    let _ = writeln!(out, "# E2e tests for category: {category}");
312    let _ = writeln!(out, "set -euo pipefail");
313    let _ = writeln!(out);
314
315    for fixture in fixtures {
316        render_test_function(
317            &mut out,
318            fixture,
319            binary_name,
320            subcommand,
321            static_cli_args,
322            cli_flags,
323            args,
324            field_resolver,
325            e2e_config,
326        );
327        let _ = writeln!(out);
328    }
329
330    // Emit a runner function for this category.
331    let _ = writeln!(out, "run_tests_{safe_category}() {{");
332    for fixture in fixtures {
333        let fn_name = sanitize_ident(&fixture.id);
334        let _ = writeln!(out, "    run_test test_{fn_name}");
335    }
336    let _ = writeln!(out, "}}");
337    out
338}
339
340/// Render a single `test_{id}()` function for a fixture.
341#[allow(clippy::too_many_arguments)]
342fn render_test_function(
343    out: &mut String,
344    fixture: &Fixture,
345    binary_name: &str,
346    subcommand: &str,
347    static_cli_args: &[String],
348    cli_flags: &std::collections::HashMap<String, String>,
349    _args: &[crate::config::ArgMapping],
350    field_resolver: &FieldResolver,
351    e2e_config: &E2eConfig,
352) {
353    let fn_name = sanitize_ident(&fixture.id);
354    let description = &fixture.description;
355
356    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
357
358    let _ = writeln!(out, "test_{fn_name}() {{");
359    let _ = writeln!(out, "    # {description}");
360
361    // Resolve fixture-specific call config if provided, otherwise use defaults.
362    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
363
364    // Build the CLI command using the resolved call config.
365    let cmd_parts = build_cli_command(
366        fixture,
367        binary_name,
368        subcommand,
369        static_cli_args,
370        cli_flags,
371        &call_config.args,
372    );
373
374    if expects_error {
375        let cmd = cmd_parts.join(" ");
376        let _ = writeln!(out, "    if {cmd} >/dev/null 2>&1; then");
377        let _ = writeln!(
378            out,
379            "        echo 'FAIL [error]: expected command to fail but it succeeded' >&2"
380        );
381        let _ = writeln!(out, "        return 1");
382        let _ = writeln!(out, "    fi");
383        let _ = writeln!(out, "}}");
384        return;
385    }
386
387    // Check if any assertion will actually emit code (not be skipped).
388    let has_active_assertions = fixture.assertions.iter().any(|a| {
389        a.field
390            .as_ref()
391            .is_none_or(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
392    });
393
394    // Capture output (only if there are active assertions that reference it).
395    let cmd = cmd_parts.join(" ");
396    if has_active_assertions {
397        let _ = writeln!(out, "    local output");
398        let _ = writeln!(out, "    output=$({cmd})");
399    } else {
400        let _ = writeln!(out, "    {cmd} >/dev/null");
401    }
402    let _ = writeln!(out);
403
404    // Emit assertions.
405    for assertion in &fixture.assertions {
406        render_assertion(out, assertion, binary_name, field_resolver);
407    }
408
409    let _ = writeln!(out, "}}");
410}
411
412/// Build the shell CLI invocation as a list of tokens.
413///
414/// Tokens are returned unquoted where safe (flag names) or single-quoted
415/// (string values from the fixture).
416fn build_cli_command(
417    fixture: &Fixture,
418    binary_name: &str,
419    subcommand: &str,
420    static_cli_args: &[String],
421    cli_flags: &std::collections::HashMap<String, String>,
422    args: &[crate::config::ArgMapping],
423) -> Vec<String> {
424    let mut parts: Vec<String> = vec![binary_name.to_string(), subcommand.to_string()];
425
426    for arg in args {
427        match arg.arg_type.as_str() {
428            "mock_url" => {
429                // Positional URL argument.
430                parts.push(format!("\"${{MOCK_SERVER_URL}}/fixtures/{}\"", fixture.id));
431            }
432            "handle" => {
433                // CLI manages its own engine; skip handle args.
434            }
435            _ => {
436                // Check if there is a cli_flags mapping for this field.
437                if let Some(flag) = cli_flags.get(&arg.field) {
438                    let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
439                    if let Some(val) = fixture.input.get(field) {
440                        if !val.is_null() {
441                            let val_str = json_value_to_shell_arg(val);
442                            parts.push(flag.clone());
443                            parts.push(val_str);
444                        }
445                    }
446                }
447            }
448        }
449    }
450
451    // Append static CLI args last.
452    for static_arg in static_cli_args {
453        parts.push(static_arg.clone());
454    }
455
456    parts
457}
458
459/// Convert a JSON value to a shell argument string.
460///
461/// Strings are wrapped in single quotes with embedded single quotes escaped.
462/// Numbers and booleans are emitted verbatim.
463fn json_value_to_shell_arg(value: &serde_json::Value) -> String {
464    match value {
465        serde_json::Value::String(s) => format!("'{}'", escape_shell(s)),
466        serde_json::Value::Bool(b) => b.to_string(),
467        serde_json::Value::Number(n) => n.to_string(),
468        serde_json::Value::Null => "''".to_string(),
469        other => format!("'{}'", escape_shell(&other.to_string())),
470    }
471}
472
473/// Convert a fixture field path to a jq expression.
474///
475/// A path like `metadata.title` becomes `.metadata.title`.
476/// An array field like `links` becomes `.links`.
477/// The pseudo-property `length` (also `count`, `size`) becomes `| length`
478/// because jq uses pipe syntax for the `length` builtin.
479fn field_to_jq_path(resolved: &str) -> String {
480    // Check if the path ends with a length/count/size pseudo-property.
481    // E.g., "pages.length" → ".pages | length"
482    if let Some((prefix, suffix)) = resolved.rsplit_once('.') {
483        if suffix == "length" || suffix == "count" || suffix == "size" {
484            return format!(".{prefix} | length");
485        }
486    }
487    // Handle bare "length" / "count" / "size" (top-level array).
488    if resolved == "length" || resolved == "count" || resolved == "size" {
489        return ". | length".to_string();
490    }
491    format!(".{resolved}")
492}
493
494/// Build a CLI command for a method_result assertion.
495///
496/// Uses generic dispatch: `{binary_name} {kebab-method} "$output" args...`.
497/// The method name is converted from snake_case to kebab-case for the CLI subcommand.
498/// Args from the fixture JSON object are emitted as positional shell arguments in
499/// insertion order, using best-effort shell quoting.
500fn build_brew_method_call(binary_name: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
501    let subcommand = method_name.replace('_', "-");
502    if let Some(args_val) = args {
503        let arg_str = args_val
504            .as_object()
505            .map(|obj| {
506                obj.values()
507                    .map(|v| match v {
508                        serde_json::Value::String(s) => format!("'{}'", escape_shell(s)),
509                        other => other.to_string(),
510                    })
511                    .collect::<Vec<_>>()
512                    .join(" ")
513            })
514            .unwrap_or_default();
515        if arg_str.is_empty() {
516            format!("{binary_name} {subcommand} \"$output\"")
517        } else {
518            format!("{binary_name} {subcommand} \"$output\" {arg_str}")
519        }
520    } else {
521        format!("{binary_name} {subcommand} \"$output\"")
522    }
523}
524
525/// Render a single assertion as shell code.
526fn render_assertion(out: &mut String, assertion: &Assertion, binary_name: &str, field_resolver: &FieldResolver) {
527    // Skip assertions on fields not available on the result type.
528    if let Some(f) = &assertion.field {
529        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
530            let _ = writeln!(out, "    # skipped: field '{f}' not available on result type");
531            return;
532        }
533    }
534
535    match assertion.assertion_type.as_str() {
536        "equals" => {
537            if let Some(field) = &assertion.field {
538                if let Some(expected) = &assertion.value {
539                    let resolved = field_resolver.resolve(field);
540                    let jq_path = field_to_jq_path(resolved);
541                    let expected_str = json_value_to_shell_string(expected);
542                    let safe_field = sanitize_ident(field);
543                    let _ = writeln!(out, "    local val_{safe_field}");
544                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
545                    let _ = writeln!(
546                        out,
547                        "    assert_equals \"$val_{safe_field}\" '{expected_str}' '{field}'"
548                    );
549                }
550            }
551        }
552        "contains" => {
553            if let Some(field) = &assertion.field {
554                if let Some(expected) = &assertion.value {
555                    let resolved = field_resolver.resolve(field);
556                    let jq_path = field_to_jq_path(resolved);
557                    let expected_str = json_value_to_shell_string(expected);
558                    let safe_field = sanitize_ident(field);
559                    let _ = writeln!(out, "    local val_{safe_field}");
560                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
561                    let _ = writeln!(
562                        out,
563                        "    assert_contains \"$val_{safe_field}\" '{expected_str}' '{field}'"
564                    );
565                }
566            }
567        }
568        "not_empty" | "tree_not_null" => {
569            if let Some(field) = &assertion.field {
570                let resolved = field_resolver.resolve(field);
571                let jq_path = field_to_jq_path(resolved);
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!(out, "    assert_not_empty \"$val_{safe_field}\" '{field}'");
576            }
577        }
578        "count_min" | "root_child_count_min" => {
579            if let Some(field) = &assertion.field {
580                if let Some(val) = &assertion.value {
581                    if let Some(min) = val.as_u64() {
582                        let resolved = field_resolver.resolve(field);
583                        let jq_path = field_to_jq_path(resolved);
584                        let safe_field = sanitize_ident(field);
585                        let _ = writeln!(out, "    local count_{safe_field}");
586                        let _ = writeln!(
587                            out,
588                            "    count_{safe_field}=$(echo \"$output\" | jq '{jq_path} | length')"
589                        );
590                        let _ = writeln!(out, "    assert_count_min \"$count_{safe_field}\" {min} '{field}'");
591                    }
592                }
593            }
594        }
595        "greater_than" => {
596            if let Some(field) = &assertion.field {
597                if let Some(val) = &assertion.value {
598                    let resolved = field_resolver.resolve(field);
599                    let jq_path = field_to_jq_path(resolved);
600                    let threshold = json_value_to_shell_string(val);
601                    let safe_field = sanitize_ident(field);
602                    let _ = writeln!(out, "    local val_{safe_field}");
603                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
604                    let _ = writeln!(
605                        out,
606                        "    assert_greater_than \"$val_{safe_field}\" '{threshold}' '{field}'"
607                    );
608                }
609            }
610        }
611        "greater_than_or_equal" => {
612            if let Some(field) = &assertion.field {
613                if let Some(val) = &assertion.value {
614                    let resolved = field_resolver.resolve(field);
615                    let jq_path = field_to_jq_path(resolved);
616                    let threshold = json_value_to_shell_string(val);
617                    let safe_field = sanitize_ident(field);
618                    let _ = writeln!(out, "    local val_{safe_field}");
619                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
620                    let _ = writeln!(
621                        out,
622                        "    assert_greater_than_or_equal \"$val_{safe_field}\" '{threshold}' '{field}'"
623                    );
624                }
625            }
626        }
627        "contains_all" => {
628            if let Some(field) = &assertion.field {
629                if let Some(serde_json::Value::Array(items)) = &assertion.value {
630                    let resolved = field_resolver.resolve(field);
631                    let jq_path = field_to_jq_path(resolved);
632                    let safe_field = sanitize_ident(field);
633                    let _ = writeln!(out, "    local val_{safe_field}");
634                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
635                    for (index, item) in items.iter().enumerate() {
636                        let item_str = json_value_to_shell_string(item);
637                        let _ = writeln!(
638                            out,
639                            "    assert_contains \"$val_{safe_field}\" '{item_str}' '{field}[{index}]'"
640                        );
641                    }
642                }
643            }
644        }
645        "is_empty" => {
646            if let Some(field) = &assertion.field {
647                let resolved = field_resolver.resolve(field);
648                let jq_path = field_to_jq_path(resolved);
649                let safe_field = sanitize_ident(field);
650                let _ = writeln!(out, "    local val_{safe_field}");
651                let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
652                let _ = writeln!(out, "    assert_is_empty \"$val_{safe_field}\" '{field}'");
653            }
654        }
655        "less_than" => {
656            if let Some(field) = &assertion.field {
657                if let Some(val) = &assertion.value {
658                    let resolved = field_resolver.resolve(field);
659                    let jq_path = field_to_jq_path(resolved);
660                    let threshold = json_value_to_shell_string(val);
661                    let safe_field = sanitize_ident(field);
662                    let _ = writeln!(out, "    local val_{safe_field}");
663                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
664                    let _ = writeln!(
665                        out,
666                        "    assert_less_than \"$val_{safe_field}\" '{threshold}' '{field}'"
667                    );
668                }
669            }
670        }
671        "not_contains" => {
672            if let Some(field) = &assertion.field {
673                if let Some(expected) = &assertion.value {
674                    let resolved = field_resolver.resolve(field);
675                    let jq_path = field_to_jq_path(resolved);
676                    let expected_str = json_value_to_shell_string(expected);
677                    let safe_field = sanitize_ident(field);
678                    let _ = writeln!(out, "    local val_{safe_field}");
679                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
680                    let _ = writeln!(
681                        out,
682                        "    assert_not_contains \"$val_{safe_field}\" '{expected_str}' '{field}'"
683                    );
684                }
685            }
686        }
687        "count_equals" => {
688            if let Some(field) = &assertion.field {
689                if let Some(val) = &assertion.value {
690                    if let Some(n) = val.as_u64() {
691                        let resolved = field_resolver.resolve(field);
692                        let jq_path = field_to_jq_path(resolved);
693                        let safe_field = sanitize_ident(field);
694                        let _ = writeln!(out, "    local count_{safe_field}");
695                        let _ = writeln!(
696                            out,
697                            "    count_{safe_field}=$(echo \"$output\" | jq '{jq_path} | length')"
698                        );
699                        let _ = writeln!(out, "    [ \"$count_{safe_field}\" -eq {n} ] || exit 1");
700                    }
701                }
702            }
703        }
704        "is_true" => {
705            if let Some(field) = &assertion.field {
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 val_{safe_field}");
710                let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
711                let _ = writeln!(out, "    [ \"$val_{safe_field}\" = \"true\" ] || exit 1");
712            }
713        }
714        "is_false" => {
715            if let Some(field) = &assertion.field {
716                let resolved = field_resolver.resolve(field);
717                let jq_path = field_to_jq_path(resolved);
718                let safe_field = sanitize_ident(field);
719                let _ = writeln!(out, "    local val_{safe_field}");
720                let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
721                let _ = writeln!(out, "    [ \"$val_{safe_field}\" = \"false\" ] || exit 1");
722            }
723        }
724        "less_than_or_equal" => {
725            if let Some(field) = &assertion.field {
726                if let Some(val) = &assertion.value {
727                    let resolved = field_resolver.resolve(field);
728                    let jq_path = field_to_jq_path(resolved);
729                    let threshold = json_value_to_shell_string(val);
730                    let safe_field = sanitize_ident(field);
731                    let _ = writeln!(out, "    local val_{safe_field}");
732                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
733                    let _ = writeln!(
734                        out,
735                        "    assert_less_than_or_equal \"$val_{safe_field}\" '{threshold}' '{field}'"
736                    );
737                }
738            }
739        }
740        "method_result" => {
741            if let Some(method_name) = &assertion.method {
742                let check = assertion.check.as_deref().unwrap_or("is_true");
743                let cmd = build_brew_method_call(binary_name, method_name, assertion.args.as_ref());
744                // For is_error, skip capturing the result — just run the command and check
745                // the exit code so we don't execute the method twice.
746                if check == "is_error" {
747                    let _ = writeln!(out, "    if {cmd} >/dev/null 2>&1; then");
748                    let _ = writeln!(
749                        out,
750                        "        echo 'FAIL [method_result]: expected method to raise error but it succeeded' >&2"
751                    );
752                    let _ = writeln!(out, "        return 1");
753                    let _ = writeln!(out, "    fi");
754                } else {
755                    let method_var = format!("method_result_{}", sanitize_ident(method_name));
756                    let _ = writeln!(out, "    local {method_var}");
757                    let _ = writeln!(out, "    {method_var}=$({cmd})");
758                    match check {
759                        "equals" => {
760                            if let Some(val) = &assertion.value {
761                                let expected = json_value_to_shell_string(val);
762                                let _ = writeln!(out, "    [ \"${method_var}\" = '{expected}' ] || exit 1");
763                            }
764                        }
765                        "is_true" => {
766                            let _ = writeln!(out, "    [ \"${method_var}\" = \"true\" ] || exit 1");
767                        }
768                        "is_false" => {
769                            let _ = writeln!(out, "    [ \"${method_var}\" = \"false\" ] || exit 1");
770                        }
771                        "greater_than_or_equal" => {
772                            if let Some(val) = &assertion.value {
773                                if let Some(n) = val.as_u64() {
774                                    let _ = writeln!(out, "    [ \"${method_var}\" -ge {n} ] || exit 1");
775                                }
776                            }
777                        }
778                        "count_min" => {
779                            if let Some(val) = &assertion.value {
780                                if let Some(n) = val.as_u64() {
781                                    let _ = writeln!(
782                                        out,
783                                        "    local count_from_method_result=$(echo \"${method_var}\" | jq 'length')"
784                                    );
785                                    let _ = writeln!(out, "    [ \"$count_from_method_result\" -ge {n} ] || exit 1");
786                                }
787                            }
788                        }
789                        "contains" => {
790                            if let Some(val) = &assertion.value {
791                                let expected = json_value_to_shell_string(val);
792                                let _ = writeln!(out, "    [[ \"${method_var}\" == *'{expected}'* ]] || exit 1");
793                            }
794                        }
795                        other_check => {
796                            panic!("Brew e2e generator: unsupported method_result check type: {other_check}");
797                        }
798                    }
799                }
800            } else {
801                panic!("method_result assertion missing 'method' field");
802            }
803        }
804        "min_length" => {
805            if let Some(field) = &assertion.field {
806                if let Some(val) = &assertion.value {
807                    if let Some(n) = val.as_u64() {
808                        let resolved = field_resolver.resolve(field);
809                        let jq_path = field_to_jq_path(resolved);
810                        let safe_field = sanitize_ident(field);
811                        let _ = writeln!(out, "    local val_{safe_field}");
812                        let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
813                        let _ = writeln!(
814                            out,
815                            "    [ \"${{#val_{safe_field}}}\" -ge {n} ] || {{ echo \"FAIL [{field}]: expected length >= {n}\" >&2; return 1; }}"
816                        );
817                    }
818                }
819            }
820        }
821        "max_length" => {
822            if let Some(field) = &assertion.field {
823                if let Some(val) = &assertion.value {
824                    if let Some(n) = val.as_u64() {
825                        let resolved = field_resolver.resolve(field);
826                        let jq_path = field_to_jq_path(resolved);
827                        let safe_field = sanitize_ident(field);
828                        let _ = writeln!(out, "    local val_{safe_field}");
829                        let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
830                        let _ = writeln!(
831                            out,
832                            "    [ \"${{#val_{safe_field}}}\" -le {n} ] || {{ echo \"FAIL [{field}]: expected length <= {n}\" >&2; return 1; }}"
833                        );
834                    }
835                }
836            }
837        }
838        "ends_with" => {
839            if let Some(field) = &assertion.field {
840                if let Some(expected) = &assertion.value {
841                    let resolved = field_resolver.resolve(field);
842                    let jq_path = field_to_jq_path(resolved);
843                    let expected_str = json_value_to_shell_string(expected);
844                    let safe_field = sanitize_ident(field);
845                    let _ = writeln!(out, "    local val_{safe_field}");
846                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
847                    let _ = writeln!(
848                        out,
849                        "    [[ \"$val_{safe_field}\" == *'{expected_str}' ]] || {{ echo \"FAIL [{field}]: expected to end with '{expected_str}'\" >&2; return 1; }}"
850                    );
851                }
852            }
853        }
854        "matches_regex" => {
855            if let Some(field) = &assertion.field {
856                if let Some(expected) = &assertion.value {
857                    if let Some(pattern) = expected.as_str() {
858                        let resolved = field_resolver.resolve(field);
859                        let jq_path = field_to_jq_path(resolved);
860                        let safe_field = sanitize_ident(field);
861                        let _ = writeln!(out, "    local val_{safe_field}");
862                        let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
863                        let _ = writeln!(
864                            out,
865                            "    [[ \"$val_{safe_field}\" =~ {pattern} ]] || {{ echo \"FAIL [{field}]: expected to match /{pattern}/\" >&2; return 1; }}"
866                        );
867                    }
868                }
869            }
870        }
871        "not_error" => {
872            // No-op: reaching this point means the call succeeded.
873        }
874        "error" => {
875            // Handled at the function level (early return above).
876        }
877        other => {
878            panic!("Brew e2e generator: unsupported assertion type: {other}");
879        }
880    }
881}
882
883/// Convert a JSON value to a plain string suitable for use in shell assertions.
884///
885/// Returns the bare string content (no quotes) — callers wrap in single quotes.
886fn json_value_to_shell_string(value: &serde_json::Value) -> String {
887    match value {
888        serde_json::Value::String(s) => escape_shell(s),
889        serde_json::Value::Bool(b) => b.to_string(),
890        serde_json::Value::Number(n) => n.to_string(),
891        serde_json::Value::Null => String::new(),
892        other => escape_shell(&other.to_string()),
893    }
894}