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