Skip to main content

alef_e2e/codegen/
brew.rs

1//! Brew (Homebrew CLI) e2e test generator.
2//!
3//! Generates a self-contained shell-script test suite that tests a CLI binary
4//! installed via Homebrew.  The suite consists of:
5//!
6//! - `run_tests.sh` — main runner that sources per-category files, tracks
7//!   pass/fail counts and exits 1 on any failure.
8//! - `test_{category}.sh` — one file per fixture category, each containing
9//!   a `test_{fixture_id}()` shell function.
10//!
11//! Each test function:
12//! 1. Constructs a CLI invocation: `{binary} {subcommand} "{url}" {flags...}`
13//! 2. Captures stdout into a variable.
14//! 3. Uses `jq` to extract fields and runs helper assertion functions.
15//!
16//! Requirements at runtime: `bash`, `jq`, and `MOCK_SERVER_URL` env var.
17
18use crate::config::E2eConfig;
19use crate::escape::{escape_shell, sanitize_filename, sanitize_ident};
20use crate::field_access::FieldResolver;
21use crate::fixture::{Assertion, Fixture, FixtureGroup};
22use alef_core::backend::GeneratedFile;
23use alef_core::config::AlefConfig;
24use anyhow::Result;
25use std::fmt::Write as FmtWrite;
26use std::path::PathBuf;
27
28use super::E2eCodegen;
29
30/// Brew (Homebrew CLI) e2e code generator.
31pub struct BrewCodegen;
32
33impl E2eCodegen for BrewCodegen {
34    fn generate(
35        &self,
36        groups: &[FixtureGroup],
37        e2e_config: &E2eConfig,
38        _alef_config: &AlefConfig,
39    ) -> Result<Vec<GeneratedFile>> {
40        let lang = self.language_name();
41        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
42
43        // Resolve call config with overrides for the "brew" language key.
44        let call = &e2e_config.call;
45        let overrides = call.overrides.get(lang);
46        let subcommand = overrides
47            .and_then(|o| o.function.as_ref())
48            .cloned()
49            .unwrap_or_else(|| call.function.clone());
50
51        // Static CLI flags appended to every invocation.
52        let static_cli_args: Vec<String> = overrides.map(|o| o.cli_args.clone()).unwrap_or_default();
53
54        // Field-to-flag mapping (fixture input field → CLI flag name).
55        let cli_flags: std::collections::HashMap<String, String> =
56            overrides.map(|o| o.cli_flags.clone()).unwrap_or_default();
57
58        // Resolve binary name from the "brew" package entry, falling back to call.module.
59        let binary_name = e2e_config
60            .registry
61            .packages
62            .get(lang)
63            .and_then(|p| p.name.as_ref())
64            .cloned()
65            .or_else(|| e2e_config.packages.get(lang).and_then(|p| p.name.as_ref()).cloned())
66            .unwrap_or_else(|| call.module.clone());
67
68        // Filter active groups (non-skipped fixtures).
69        let active_groups: Vec<(&FixtureGroup, Vec<&Fixture>)> = groups
70            .iter()
71            .filter_map(|group| {
72                let active: Vec<&Fixture> = group
73                    .fixtures
74                    .iter()
75                    .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
76                    .collect();
77                if active.is_empty() { None } else { Some((group, active)) }
78            })
79            .collect();
80
81        let field_resolver = FieldResolver::new(
82            &e2e_config.fields,
83            &e2e_config.fields_optional,
84            &e2e_config.result_fields,
85            &e2e_config.fields_array,
86        );
87
88        let mut files = Vec::new();
89
90        // Generate run_tests.sh.
91        let category_names: Vec<String> = active_groups
92            .iter()
93            .map(|(g, _)| sanitize_filename(&g.category))
94            .collect();
95        files.push(GeneratedFile {
96            path: output_base.join("run_tests.sh"),
97            content: render_run_tests(&category_names),
98            generated_header: true,
99        });
100
101        // Generate per-category test files.
102        for (group, active) in &active_groups {
103            let safe_category = sanitize_filename(&group.category);
104            let filename = format!("test_{safe_category}.sh");
105            let content = render_category_file(
106                &group.category,
107                active,
108                &binary_name,
109                &subcommand,
110                &static_cli_args,
111                &cli_flags,
112                &e2e_config.call.args,
113                &field_resolver,
114            );
115            files.push(GeneratedFile {
116                path: output_base.join(filename),
117                content,
118                generated_header: true,
119            });
120        }
121
122        Ok(files)
123    }
124
125    fn language_name(&self) -> &'static str {
126        "brew"
127    }
128}
129
130/// Render the main `run_tests.sh` runner script.
131fn render_run_tests(categories: &[String]) -> String {
132    let mut out = String::new();
133    let _ = writeln!(out, "#!/usr/bin/env bash");
134    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
135    let _ = writeln!(out, "# shellcheck disable=SC1091");
136    let _ = writeln!(out, "set -euo pipefail");
137    let _ = writeln!(out);
138    let _ = writeln!(out, "# MOCK_SERVER_URL must be set to the base URL of the mock server.");
139    let _ = writeln!(out, ": \"${{MOCK_SERVER_URL:?MOCK_SERVER_URL is required}}\"");
140    let _ = writeln!(out);
141    let _ = writeln!(out, "# Verify that jq is available.");
142    let _ = writeln!(out, "if ! command -v jq &>/dev/null; then");
143    let _ = writeln!(out, "    echo 'error: jq is required but not found in PATH' >&2");
144    let _ = writeln!(out, "    exit 1");
145    let _ = writeln!(out, "fi");
146    let _ = writeln!(out);
147    let _ = writeln!(out, "PASS=0");
148    let _ = writeln!(out, "FAIL=0");
149    let _ = writeln!(out);
150
151    // Helper functions.
152    let _ = writeln!(out, "assert_equals() {{");
153    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
154    let _ = writeln!(out, "    if [ \"$actual\" != \"$expected\" ]; then");
155    let _ = writeln!(
156        out,
157        "        echo \"FAIL [$label]: expected '$expected', got '$actual'\" >&2"
158    );
159    let _ = writeln!(out, "        return 1");
160    let _ = writeln!(out, "    fi");
161    let _ = writeln!(out, "}}");
162    let _ = writeln!(out);
163    let _ = writeln!(out, "assert_contains() {{");
164    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
165    let _ = writeln!(out, "    if [[ \"$actual\" != *\"$expected\"* ]]; then");
166    let _ = writeln!(
167        out,
168        "        echo \"FAIL [$label]: expected to contain '$expected'\" >&2"
169    );
170    let _ = writeln!(out, "        return 1");
171    let _ = writeln!(out, "    fi");
172    let _ = writeln!(out, "}}");
173    let _ = writeln!(out);
174    let _ = writeln!(out, "assert_not_empty() {{");
175    let _ = writeln!(out, "    local actual=\"$1\" label=\"$2\"");
176    let _ = writeln!(out, "    if [ -z \"$actual\" ]; then");
177    let _ = writeln!(out, "        echo \"FAIL [$label]: expected non-empty value\" >&2");
178    let _ = writeln!(out, "        return 1");
179    let _ = writeln!(out, "    fi");
180    let _ = writeln!(out, "}}");
181    let _ = writeln!(out);
182    let _ = writeln!(out, "assert_count_min() {{");
183    let _ = writeln!(out, "    local count=\"$1\" min=\"$2\" label=\"$3\"");
184    let _ = writeln!(out, "    if [ \"$count\" -lt \"$min\" ]; then");
185    let _ = writeln!(
186        out,
187        "        echo \"FAIL [$label]: expected at least $min elements, got $count\" >&2"
188    );
189    let _ = writeln!(out, "        return 1");
190    let _ = writeln!(out, "    fi");
191    let _ = writeln!(out, "}}");
192    let _ = writeln!(out);
193    let _ = writeln!(out, "assert_greater_than() {{");
194    let _ = writeln!(out, "    local val=\"$1\" threshold=\"$2\" label=\"$3\"");
195    let _ = writeln!(
196        out,
197        "    if [ \"$(echo \"$val > $threshold\" | bc -l)\" != \"1\" ]; then"
198    );
199    let _ = writeln!(out, "        echo \"FAIL [$label]: expected $val > $threshold\" >&2");
200    let _ = writeln!(out, "        return 1");
201    let _ = writeln!(out, "    fi");
202    let _ = writeln!(out, "}}");
203    let _ = writeln!(out);
204    let _ = writeln!(out, "assert_greater_than_or_equal() {{");
205    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
206    let _ = writeln!(out, "    if [ \"$actual\" -lt \"$expected\" ]; then");
207    let _ = writeln!(out, "        echo \"FAIL [$label]: expected $actual >= $expected\" >&2");
208    let _ = writeln!(out, "        return 1");
209    let _ = writeln!(out, "    fi");
210    let _ = writeln!(out, "}}");
211    let _ = writeln!(out);
212    let _ = writeln!(out, "assert_is_empty() {{");
213    let _ = writeln!(out, "    local actual=\"$1\" label=\"$2\"");
214    let _ = writeln!(out, "    if [ -n \"$actual\" ]; then");
215    let _ = writeln!(
216        out,
217        "        echo \"FAIL [$label]: expected empty value, got '$actual'\" >&2"
218    );
219    let _ = writeln!(out, "        return 1");
220    let _ = writeln!(out, "    fi");
221    let _ = writeln!(out, "}}");
222    let _ = writeln!(out);
223    let _ = writeln!(out, "assert_less_than() {{");
224    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
225    let _ = writeln!(out, "    if [ \"$actual\" -ge \"$expected\" ]; then");
226    let _ = writeln!(out, "        echo \"FAIL [$label]: expected $actual < $expected\" >&2");
227    let _ = writeln!(out, "        return 1");
228    let _ = writeln!(out, "    fi");
229    let _ = writeln!(out, "}}");
230    let _ = writeln!(out);
231    let _ = writeln!(out, "assert_not_contains() {{");
232    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
233    let _ = writeln!(out, "    if [[ \"$actual\" == *\"$expected\"* ]]; then");
234    let _ = writeln!(
235        out,
236        "        echo \"FAIL [$label]: expected not to contain '$expected'\" >&2"
237    );
238    let _ = writeln!(out, "        return 1");
239    let _ = writeln!(out, "    fi");
240    let _ = writeln!(out, "}}");
241    let _ = writeln!(out);
242
243    // Source per-category files.
244    let script_dir = r#"SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)""#;
245    let _ = writeln!(out, "{script_dir}");
246    let _ = writeln!(out);
247    for category in categories {
248        let _ = writeln!(out, "# shellcheck source=test_{category}.sh");
249        let _ = writeln!(out, "source \"$SCRIPT_DIR/test_{category}.sh\"");
250    }
251    let _ = writeln!(out);
252
253    // Run each test function and track pass/fail.
254    let _ = writeln!(out, "run_test() {{");
255    let _ = writeln!(out, "    local name=\"$1\"");
256    let _ = writeln!(out, "    if \"$name\"; then");
257    let _ = writeln!(out, "        echo \"PASS: $name\"");
258    let _ = writeln!(out, "        PASS=$((PASS + 1))");
259    let _ = writeln!(out, "    else");
260    let _ = writeln!(out, "        echo \"FAIL: $name\"");
261    let _ = writeln!(out, "        FAIL=$((FAIL + 1))");
262    let _ = writeln!(out, "    fi");
263    let _ = writeln!(out, "}}");
264    let _ = writeln!(out);
265
266    // Gather all test function names from category files then call them.
267    // We enumerate them at code-generation time so the runner doesn't need
268    // introspection at runtime.
269    let _ = writeln!(out, "# Run all generated test functions.");
270    for category in categories {
271        let _ = writeln!(out, "# Category: {category}");
272        // We emit a placeholder comment — the actual list is per-category.
273        // The run_test calls are emitted inline below based on known IDs.
274        let _ = writeln!(out, "run_tests_{category}");
275    }
276    let _ = writeln!(out);
277    let _ = writeln!(out, "echo \"\"");
278    let _ = writeln!(out, "echo \"Results: $PASS passed, $FAIL failed\"");
279    let _ = writeln!(out, "[ \"$FAIL\" -eq 0 ]");
280    out
281}
282
283/// Render a per-category `test_{category}.sh` file.
284#[allow(clippy::too_many_arguments)]
285fn render_category_file(
286    category: &str,
287    fixtures: &[&Fixture],
288    binary_name: &str,
289    subcommand: &str,
290    static_cli_args: &[String],
291    cli_flags: &std::collections::HashMap<String, String>,
292    args: &[crate::config::ArgMapping],
293    field_resolver: &FieldResolver,
294) -> String {
295    let safe_category = sanitize_filename(category);
296    let mut out = String::new();
297    let _ = writeln!(out, "#!/usr/bin/env bash");
298    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
299    let _ = writeln!(out, "# E2e tests for category: {category}");
300    let _ = writeln!(out, "set -euo pipefail");
301    let _ = writeln!(out);
302
303    for fixture in fixtures {
304        render_test_function(
305            &mut out,
306            fixture,
307            binary_name,
308            subcommand,
309            static_cli_args,
310            cli_flags,
311            args,
312            field_resolver,
313        );
314        let _ = writeln!(out);
315    }
316
317    // Emit a runner function for this category.
318    let _ = writeln!(out, "run_tests_{safe_category}() {{");
319    for fixture in fixtures {
320        let fn_name = sanitize_ident(&fixture.id);
321        let _ = writeln!(out, "    run_test test_{fn_name}");
322    }
323    let _ = writeln!(out, "}}");
324    out
325}
326
327/// Render a single `test_{id}()` function for a fixture.
328#[allow(clippy::too_many_arguments)]
329fn render_test_function(
330    out: &mut String,
331    fixture: &Fixture,
332    binary_name: &str,
333    subcommand: &str,
334    static_cli_args: &[String],
335    cli_flags: &std::collections::HashMap<String, String>,
336    args: &[crate::config::ArgMapping],
337    field_resolver: &FieldResolver,
338) {
339    let fn_name = sanitize_ident(&fixture.id);
340    let description = &fixture.description;
341
342    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
343
344    let _ = writeln!(out, "test_{fn_name}() {{");
345    let _ = writeln!(out, "    # {description}");
346
347    // Build the CLI command.
348    let cmd_parts = build_cli_command(fixture, binary_name, subcommand, static_cli_args, cli_flags, args);
349
350    if expects_error {
351        let cmd = cmd_parts.join(" ");
352        let _ = writeln!(out, "    if {cmd} >/dev/null 2>&1; then");
353        let _ = writeln!(
354            out,
355            "        echo 'FAIL [error]: expected command to fail but it succeeded' >&2"
356        );
357        let _ = writeln!(out, "        return 1");
358        let _ = writeln!(out, "    fi");
359        let _ = writeln!(out, "}}");
360        return;
361    }
362
363    // Check if any assertion will actually emit code (not be skipped).
364    let has_active_assertions = fixture.assertions.iter().any(|a| {
365        a.field
366            .as_ref()
367            .is_none_or(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
368    });
369
370    // Capture output (only if there are active assertions that reference it).
371    let cmd = cmd_parts.join(" ");
372    if has_active_assertions {
373        let _ = writeln!(out, "    local output");
374        let _ = writeln!(out, "    output=$({cmd})");
375    } else {
376        let _ = writeln!(out, "    {cmd} >/dev/null");
377    }
378    let _ = writeln!(out);
379
380    // Emit assertions.
381    for assertion in &fixture.assertions {
382        render_assertion(out, assertion, field_resolver);
383    }
384
385    let _ = writeln!(out, "}}");
386}
387
388/// Build the shell CLI invocation as a list of tokens.
389///
390/// Tokens are returned unquoted where safe (flag names) or single-quoted
391/// (string values from the fixture).
392fn build_cli_command(
393    fixture: &Fixture,
394    binary_name: &str,
395    subcommand: &str,
396    static_cli_args: &[String],
397    cli_flags: &std::collections::HashMap<String, String>,
398    args: &[crate::config::ArgMapping],
399) -> Vec<String> {
400    let mut parts: Vec<String> = vec![binary_name.to_string(), subcommand.to_string()];
401
402    for arg in args {
403        match arg.arg_type.as_str() {
404            "mock_url" => {
405                // Positional URL argument.
406                parts.push(format!("\"${{MOCK_SERVER_URL}}/fixtures/{}\"", fixture.id));
407            }
408            "handle" => {
409                // CLI manages its own engine; skip handle args.
410            }
411            _ => {
412                // Check if there is a cli_flags mapping for this field.
413                if let Some(flag) = cli_flags.get(&arg.field) {
414                    let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
415                    if let Some(val) = fixture.input.get(field) {
416                        if !val.is_null() {
417                            let val_str = json_value_to_shell_arg(val);
418                            parts.push(flag.clone());
419                            parts.push(val_str);
420                        }
421                    }
422                }
423            }
424        }
425    }
426
427    // Append static CLI args last.
428    for static_arg in static_cli_args {
429        parts.push(static_arg.clone());
430    }
431
432    parts
433}
434
435/// Convert a JSON value to a shell argument string.
436///
437/// Strings are wrapped in single quotes with embedded single quotes escaped.
438/// Numbers and booleans are emitted verbatim.
439fn json_value_to_shell_arg(value: &serde_json::Value) -> String {
440    match value {
441        serde_json::Value::String(s) => format!("'{}'", escape_shell(s)),
442        serde_json::Value::Bool(b) => b.to_string(),
443        serde_json::Value::Number(n) => n.to_string(),
444        serde_json::Value::Null => "''".to_string(),
445        other => format!("'{}'", escape_shell(&other.to_string())),
446    }
447}
448
449/// Convert a fixture field path to a jq expression.
450///
451/// A path like `metadata.title` becomes `.metadata.title`.
452/// An array field like `links` becomes `.links`.
453/// The pseudo-property `length` (also `count`, `size`) becomes `| length`
454/// because jq uses pipe syntax for the `length` builtin.
455fn field_to_jq_path(resolved: &str) -> String {
456    // Check if the path ends with a length/count/size pseudo-property.
457    // E.g., "pages.length" → ".pages | length"
458    if let Some((prefix, suffix)) = resolved.rsplit_once('.') {
459        if suffix == "length" || suffix == "count" || suffix == "size" {
460            return format!(".{prefix} | length");
461        }
462    }
463    // Handle bare "length" / "count" / "size" (top-level array).
464    if resolved == "length" || resolved == "count" || resolved == "size" {
465        return ". | length".to_string();
466    }
467    format!(".{resolved}")
468}
469
470/// Render a single assertion as shell code.
471fn render_assertion(out: &mut String, assertion: &Assertion, field_resolver: &FieldResolver) {
472    // Skip assertions on fields not available on the result type.
473    if let Some(f) = &assertion.field {
474        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
475            let _ = writeln!(out, "    # skipped: field '{f}' not available on result type");
476            return;
477        }
478    }
479
480    match assertion.assertion_type.as_str() {
481        "equals" => {
482            if let Some(field) = &assertion.field {
483                if let Some(expected) = &assertion.value {
484                    let resolved = field_resolver.resolve(field);
485                    let jq_path = field_to_jq_path(resolved);
486                    let expected_str = json_value_to_shell_string(expected);
487                    let safe_field = sanitize_ident(field);
488                    let _ = writeln!(out, "    local val_{safe_field}");
489                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
490                    let _ = writeln!(
491                        out,
492                        "    assert_equals \"$val_{safe_field}\" '{expected_str}' '{field}'"
493                    );
494                }
495            }
496        }
497        "contains" => {
498            if let Some(field) = &assertion.field {
499                if let Some(expected) = &assertion.value {
500                    let resolved = field_resolver.resolve(field);
501                    let jq_path = field_to_jq_path(resolved);
502                    let expected_str = json_value_to_shell_string(expected);
503                    let safe_field = sanitize_ident(field);
504                    let _ = writeln!(out, "    local val_{safe_field}");
505                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
506                    let _ = writeln!(
507                        out,
508                        "    assert_contains \"$val_{safe_field}\" '{expected_str}' '{field}'"
509                    );
510                }
511            }
512        }
513        "not_empty" | "tree_not_null" => {
514            if let Some(field) = &assertion.field {
515                let resolved = field_resolver.resolve(field);
516                let jq_path = field_to_jq_path(resolved);
517                let safe_field = sanitize_ident(field);
518                let _ = writeln!(out, "    local val_{safe_field}");
519                let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
520                let _ = writeln!(out, "    assert_not_empty \"$val_{safe_field}\" '{field}'");
521            }
522        }
523        "count_min" | "root_child_count_min" => {
524            if let Some(field) = &assertion.field {
525                if let Some(val) = &assertion.value {
526                    if let Some(min) = val.as_u64() {
527                        let resolved = field_resolver.resolve(field);
528                        let jq_path = field_to_jq_path(resolved);
529                        let safe_field = sanitize_ident(field);
530                        let _ = writeln!(out, "    local count_{safe_field}");
531                        let _ = writeln!(
532                            out,
533                            "    count_{safe_field}=$(echo \"$output\" | jq '{jq_path} | length')"
534                        );
535                        let _ = writeln!(out, "    assert_count_min \"$count_{safe_field}\" {min} '{field}'");
536                    }
537                }
538            }
539        }
540        "greater_than" => {
541            if let Some(field) = &assertion.field {
542                if let Some(val) = &assertion.value {
543                    let resolved = field_resolver.resolve(field);
544                    let jq_path = field_to_jq_path(resolved);
545                    let threshold = json_value_to_shell_string(val);
546                    let safe_field = sanitize_ident(field);
547                    let _ = writeln!(out, "    local val_{safe_field}");
548                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
549                    let _ = writeln!(
550                        out,
551                        "    assert_greater_than \"$val_{safe_field}\" '{threshold}' '{field}'"
552                    );
553                }
554            }
555        }
556        "greater_than_or_equal" => {
557            if let Some(field) = &assertion.field {
558                if let Some(val) = &assertion.value {
559                    let resolved = field_resolver.resolve(field);
560                    let jq_path = field_to_jq_path(resolved);
561                    let threshold = json_value_to_shell_string(val);
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_greater_than_or_equal \"$val_{safe_field}\" '{threshold}' '{field}'"
568                    );
569                }
570            }
571        }
572        "contains_all" => {
573            if let Some(field) = &assertion.field {
574                if let Some(serde_json::Value::Array(items)) = &assertion.value {
575                    let resolved = field_resolver.resolve(field);
576                    let jq_path = field_to_jq_path(resolved);
577                    let safe_field = sanitize_ident(field);
578                    let _ = writeln!(out, "    local val_{safe_field}");
579                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
580                    for (index, item) in items.iter().enumerate() {
581                        let item_str = json_value_to_shell_string(item);
582                        let _ = writeln!(
583                            out,
584                            "    assert_contains \"$val_{safe_field}\" '{item_str}' '{field}[{index}]'"
585                        );
586                    }
587                }
588            }
589        }
590        "is_empty" => {
591            if let Some(field) = &assertion.field {
592                let resolved = field_resolver.resolve(field);
593                let jq_path = field_to_jq_path(resolved);
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!(out, "    assert_is_empty \"$val_{safe_field}\" '{field}'");
598            }
599        }
600        "less_than" => {
601            if let Some(field) = &assertion.field {
602                if let Some(val) = &assertion.value {
603                    let resolved = field_resolver.resolve(field);
604                    let jq_path = field_to_jq_path(resolved);
605                    let threshold = json_value_to_shell_string(val);
606                    let safe_field = sanitize_ident(field);
607                    let _ = writeln!(out, "    local val_{safe_field}");
608                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
609                    let _ = writeln!(
610                        out,
611                        "    assert_less_than \"$val_{safe_field}\" '{threshold}' '{field}'"
612                    );
613                }
614            }
615        }
616        "not_contains" => {
617            if let Some(field) = &assertion.field {
618                if let Some(expected) = &assertion.value {
619                    let resolved = field_resolver.resolve(field);
620                    let jq_path = field_to_jq_path(resolved);
621                    let expected_str = json_value_to_shell_string(expected);
622                    let safe_field = sanitize_ident(field);
623                    let _ = writeln!(out, "    local val_{safe_field}");
624                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
625                    let _ = writeln!(
626                        out,
627                        "    assert_not_contains \"$val_{safe_field}\" '{expected_str}' '{field}'"
628                    );
629                }
630            }
631        }
632        "count_equals" => {
633            if let Some(field) = &assertion.field {
634                if let Some(val) = &assertion.value {
635                    if let Some(n) = val.as_u64() {
636                        let resolved = field_resolver.resolve(field);
637                        let jq_path = field_to_jq_path(resolved);
638                        let safe_field = sanitize_ident(field);
639                        let _ = writeln!(out, "    local count_{safe_field}");
640                        let _ = writeln!(
641                            out,
642                            "    count_{safe_field}=$(echo \"$output\" | jq '{jq_path} | length')"
643                        );
644                        let _ = writeln!(out, "    [ \"$count_{safe_field}\" -eq {n} ] || exit 1");
645                    }
646                }
647            }
648        }
649        "is_true" => {
650            if let Some(field) = &assertion.field {
651                let resolved = field_resolver.resolve(field);
652                let jq_path = field_to_jq_path(resolved);
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!(out, "    [ \"$val_{safe_field}\" = \"true\" ] || exit 1");
657            }
658        }
659        "not_error" => {
660            // No-op: reaching this point means the call succeeded.
661        }
662        "error" => {
663            // Handled at the function level (early return above).
664        }
665        other => {
666            let _ = writeln!(out, "    # TODO: unsupported assertion type: {other}");
667        }
668    }
669}
670
671/// Convert a JSON value to a plain string suitable for use in shell assertions.
672///
673/// Returns the bare string content (no quotes) — callers wrap in single quotes.
674fn json_value_to_shell_string(value: &serde_json::Value) -> String {
675    match value {
676        serde_json::Value::String(s) => escape_shell(s),
677        serde_json::Value::Bool(b) => b.to_string(),
678        serde_json::Value::Number(n) => n.to_string(),
679        serde_json::Value::Null => String::new(),
680        other => escape_shell(&other.to_string()),
681    }
682}