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