Skip to main content

alef_e2e/codegen/
brew.rs

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