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