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