Skip to main content

alef_e2e/codegen/
brew.rs

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