Skip to main content

alef_e2e/codegen/
brew.rs

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