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, "set -euo pipefail");
136    let _ = writeln!(out);
137    let _ = writeln!(out, "# MOCK_SERVER_URL must be set to the base URL of the mock server.");
138    let _ = writeln!(out, ": \"${{MOCK_SERVER_URL:?MOCK_SERVER_URL is required}}\"");
139    let _ = writeln!(out);
140    let _ = writeln!(out, "# Verify that jq is available.");
141    let _ = writeln!(out, "if ! command -v jq &>/dev/null; then");
142    let _ = writeln!(out, "    echo 'error: jq is required but not found in PATH' >&2");
143    let _ = writeln!(out, "    exit 1");
144    let _ = writeln!(out, "fi");
145    let _ = writeln!(out);
146    let _ = writeln!(out, "PASS=0");
147    let _ = writeln!(out, "FAIL=0");
148    let _ = writeln!(out);
149
150    // Helper functions.
151    let _ = writeln!(out, "assert_equals() {{");
152    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
153    let _ = writeln!(out, "    if [ \"$actual\" != \"$expected\" ]; then");
154    let _ = writeln!(
155        out,
156        "        echo \"FAIL [$label]: expected '$expected', got '$actual'\" >&2"
157    );
158    let _ = writeln!(out, "        return 1");
159    let _ = writeln!(out, "    fi");
160    let _ = writeln!(out, "}}");
161    let _ = writeln!(out);
162    let _ = writeln!(out, "assert_contains() {{");
163    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
164    let _ = writeln!(out, "    if [[ \"$actual\" != *\"$expected\"* ]]; then");
165    let _ = writeln!(
166        out,
167        "        echo \"FAIL [$label]: expected to contain '$expected'\" >&2"
168    );
169    let _ = writeln!(out, "        return 1");
170    let _ = writeln!(out, "    fi");
171    let _ = writeln!(out, "}}");
172    let _ = writeln!(out);
173    let _ = writeln!(out, "assert_not_empty() {{");
174    let _ = writeln!(out, "    local actual=\"$1\" label=\"$2\"");
175    let _ = writeln!(out, "    if [ -z \"$actual\" ]; then");
176    let _ = writeln!(out, "        echo \"FAIL [$label]: expected non-empty value\" >&2");
177    let _ = writeln!(out, "        return 1");
178    let _ = writeln!(out, "    fi");
179    let _ = writeln!(out, "}}");
180    let _ = writeln!(out);
181    let _ = writeln!(out, "assert_count_min() {{");
182    let _ = writeln!(out, "    local count=\"$1\" min=\"$2\" label=\"$3\"");
183    let _ = writeln!(out, "    if [ \"$count\" -lt \"$min\" ]; then");
184    let _ = writeln!(
185        out,
186        "        echo \"FAIL [$label]: expected at least $min elements, got $count\" >&2"
187    );
188    let _ = writeln!(out, "        return 1");
189    let _ = writeln!(out, "    fi");
190    let _ = writeln!(out, "}}");
191    let _ = writeln!(out);
192    let _ = writeln!(out, "assert_greater_than() {{");
193    let _ = writeln!(out, "    local val=\"$1\" threshold=\"$2\" label=\"$3\"");
194    let _ = writeln!(
195        out,
196        "    if [ \"$(echo \"$val > $threshold\" | bc -l)\" != \"1\" ]; then"
197    );
198    let _ = writeln!(out, "        echo \"FAIL [$label]: expected $val > $threshold\" >&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_greater_than_or_equal() {{");
204    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
205    let _ = writeln!(out, "    if [ \"$actual\" -lt \"$expected\" ]; then");
206    let _ = writeln!(out, "        echo \"FAIL [$label]: expected $actual >= $expected\" >&2");
207    let _ = writeln!(out, "        return 1");
208    let _ = writeln!(out, "    fi");
209    let _ = writeln!(out, "}}");
210    let _ = writeln!(out);
211    let _ = writeln!(out, "assert_is_empty() {{");
212    let _ = writeln!(out, "    local actual=\"$1\" label=\"$2\"");
213    let _ = writeln!(out, "    if [ -n \"$actual\" ]; then");
214    let _ = writeln!(
215        out,
216        "        echo \"FAIL [$label]: expected empty value, got '$actual'\" >&2"
217    );
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() {{");
223    let _ = writeln!(out, "    local actual=\"$1\" expected=\"$2\" label=\"$3\"");
224    let _ = writeln!(out, "    if [ \"$actual\" -ge \"$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    subcommand: &str,
289    static_cli_args: &[String],
290    cli_flags: &std::collections::HashMap<String, String>,
291    args: &[crate::config::ArgMapping],
292    field_resolver: &FieldResolver,
293) -> String {
294    let safe_category = sanitize_filename(category);
295    let mut out = String::new();
296    let _ = writeln!(out, "#!/usr/bin/env bash");
297    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
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            subcommand,
308            static_cli_args,
309            cli_flags,
310            args,
311            field_resolver,
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    subcommand: &str,
333    static_cli_args: &[String],
334    cli_flags: &std::collections::HashMap<String, String>,
335    args: &[crate::config::ArgMapping],
336    field_resolver: &FieldResolver,
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    // Build the CLI command.
347    let cmd_parts = build_cli_command(fixture, binary_name, subcommand, static_cli_args, cli_flags, args);
348
349    if expects_error {
350        let cmd = cmd_parts.join(" ");
351        let _ = writeln!(out, "    if {cmd} >/dev/null 2>&1; then");
352        let _ = writeln!(
353            out,
354            "        echo 'FAIL [error]: expected command to fail but it succeeded' >&2"
355        );
356        let _ = writeln!(out, "        return 1");
357        let _ = writeln!(out, "    fi");
358        let _ = writeln!(out, "}}");
359        return;
360    }
361
362    // Capture output.
363    let cmd = cmd_parts.join(" ");
364    let _ = writeln!(out, "    local output");
365    let _ = writeln!(out, "    output=$({cmd})");
366    let _ = writeln!(out);
367
368    // Emit assertions.
369    for assertion in &fixture.assertions {
370        render_assertion(out, assertion, field_resolver);
371    }
372
373    let _ = writeln!(out, "}}");
374}
375
376/// Build the shell CLI invocation as a list of tokens.
377///
378/// Tokens are returned unquoted where safe (flag names) or single-quoted
379/// (string values from the fixture).
380fn build_cli_command(
381    fixture: &Fixture,
382    binary_name: &str,
383    subcommand: &str,
384    static_cli_args: &[String],
385    cli_flags: &std::collections::HashMap<String, String>,
386    args: &[crate::config::ArgMapping],
387) -> Vec<String> {
388    let mut parts: Vec<String> = vec![binary_name.to_string(), subcommand.to_string()];
389
390    for arg in args {
391        match arg.arg_type.as_str() {
392            "mock_url" => {
393                // Positional URL argument.
394                parts.push(format!("\"${{MOCK_SERVER_URL}}/fixtures/{}\"", fixture.id));
395            }
396            "handle" => {
397                // CLI manages its own engine; skip handle args.
398            }
399            _ => {
400                // Check if there is a cli_flags mapping for this field.
401                if let Some(flag) = cli_flags.get(&arg.field) {
402                    if let Some(val) = fixture.input.get(&arg.field) {
403                        if !val.is_null() {
404                            let val_str = json_value_to_shell_arg(val);
405                            parts.push(flag.clone());
406                            parts.push(val_str);
407                        }
408                    }
409                }
410            }
411        }
412    }
413
414    // Append static CLI args last.
415    for static_arg in static_cli_args {
416        parts.push(static_arg.clone());
417    }
418
419    parts
420}
421
422/// Convert a JSON value to a shell argument string.
423///
424/// Strings are wrapped in single quotes with embedded single quotes escaped.
425/// Numbers and booleans are emitted verbatim.
426fn json_value_to_shell_arg(value: &serde_json::Value) -> String {
427    match value {
428        serde_json::Value::String(s) => format!("'{}'", escape_shell(s)),
429        serde_json::Value::Bool(b) => b.to_string(),
430        serde_json::Value::Number(n) => n.to_string(),
431        serde_json::Value::Null => "''".to_string(),
432        other => format!("'{}'", escape_shell(&other.to_string())),
433    }
434}
435
436/// Convert a fixture field path to a jq expression.
437///
438/// A path like `metadata.title` becomes `.metadata.title`.
439/// An array field like `links` becomes `.links`.
440fn field_to_jq_path(resolved: &str) -> String {
441    format!(".{resolved}")
442}
443
444/// Render a single assertion as shell code.
445fn render_assertion(out: &mut String, assertion: &Assertion, field_resolver: &FieldResolver) {
446    // Skip assertions on fields not available on the result type.
447    if let Some(f) = &assertion.field {
448        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
449            let _ = writeln!(out, "    # skipped: field '{f}' not available on result type");
450            return;
451        }
452    }
453
454    match assertion.assertion_type.as_str() {
455        "equals" => {
456            if let Some(field) = &assertion.field {
457                if let Some(expected) = &assertion.value {
458                    let resolved = field_resolver.resolve(field);
459                    let jq_path = field_to_jq_path(resolved);
460                    let expected_str = json_value_to_shell_string(expected);
461                    let safe_field = sanitize_ident(field);
462                    let _ = writeln!(out, "    local val_{safe_field}");
463                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
464                    let _ = writeln!(
465                        out,
466                        "    assert_equals \"$val_{safe_field}\" '{expected_str}' '{field}'"
467                    );
468                }
469            }
470        }
471        "contains" => {
472            if let Some(field) = &assertion.field {
473                if let Some(expected) = &assertion.value {
474                    let resolved = field_resolver.resolve(field);
475                    let jq_path = field_to_jq_path(resolved);
476                    let expected_str = json_value_to_shell_string(expected);
477                    let safe_field = sanitize_ident(field);
478                    let _ = writeln!(out, "    local val_{safe_field}");
479                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
480                    let _ = writeln!(
481                        out,
482                        "    assert_contains \"$val_{safe_field}\" '{expected_str}' '{field}'"
483                    );
484                }
485            }
486        }
487        "not_empty" | "tree_not_null" => {
488            if let Some(field) = &assertion.field {
489                let resolved = field_resolver.resolve(field);
490                let jq_path = field_to_jq_path(resolved);
491                let safe_field = sanitize_ident(field);
492                let _ = writeln!(out, "    local val_{safe_field}");
493                let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
494                let _ = writeln!(out, "    assert_not_empty \"$val_{safe_field}\" '{field}'");
495            }
496        }
497        "count_min" | "root_child_count_min" => {
498            if let Some(field) = &assertion.field {
499                if let Some(val) = &assertion.value {
500                    if let Some(min) = val.as_u64() {
501                        let resolved = field_resolver.resolve(field);
502                        let jq_path = field_to_jq_path(resolved);
503                        let safe_field = sanitize_ident(field);
504                        let _ = writeln!(out, "    local count_{safe_field}");
505                        let _ = writeln!(
506                            out,
507                            "    count_{safe_field}=$(echo \"$output\" | jq '{jq_path} | length')"
508                        );
509                        let _ = writeln!(out, "    assert_count_min \"$count_{safe_field}\" {min} '{field}'");
510                    }
511                }
512            }
513        }
514        "greater_than" => {
515            if let Some(field) = &assertion.field {
516                if let Some(val) = &assertion.value {
517                    let resolved = field_resolver.resolve(field);
518                    let jq_path = field_to_jq_path(resolved);
519                    let threshold = json_value_to_shell_string(val);
520                    let safe_field = sanitize_ident(field);
521                    let _ = writeln!(out, "    local val_{safe_field}");
522                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
523                    let _ = writeln!(
524                        out,
525                        "    assert_greater_than \"$val_{safe_field}\" '{threshold}' '{field}'"
526                    );
527                }
528            }
529        }
530        "greater_than_or_equal" => {
531            if let Some(field) = &assertion.field {
532                if let Some(val) = &assertion.value {
533                    let resolved = field_resolver.resolve(field);
534                    let jq_path = field_to_jq_path(resolved);
535                    let threshold = json_value_to_shell_string(val);
536                    let safe_field = sanitize_ident(field);
537                    let _ = writeln!(out, "    local val_{safe_field}");
538                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
539                    let _ = writeln!(
540                        out,
541                        "    assert_greater_than_or_equal \"$val_{safe_field}\" '{threshold}' '{field}'"
542                    );
543                }
544            }
545        }
546        "contains_all" => {
547            if let Some(field) = &assertion.field {
548                if let Some(serde_json::Value::Array(items)) = &assertion.value {
549                    let resolved = field_resolver.resolve(field);
550                    let jq_path = field_to_jq_path(resolved);
551                    let safe_field = sanitize_ident(field);
552                    let _ = writeln!(out, "    local val_{safe_field}");
553                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
554                    for (index, item) in items.iter().enumerate() {
555                        let item_str = json_value_to_shell_string(item);
556                        let _ = writeln!(
557                            out,
558                            "    assert_contains \"$val_{safe_field}\" '{item_str}' '{field}[{index}]'"
559                        );
560                    }
561                }
562            }
563        }
564        "is_empty" => {
565            if let Some(field) = &assertion.field {
566                let resolved = field_resolver.resolve(field);
567                let jq_path = field_to_jq_path(resolved);
568                let safe_field = sanitize_ident(field);
569                let _ = writeln!(out, "    local val_{safe_field}");
570                let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
571                let _ = writeln!(out, "    assert_is_empty \"$val_{safe_field}\" '{field}'");
572            }
573        }
574        "less_than" => {
575            if let Some(field) = &assertion.field {
576                if let Some(val) = &assertion.value {
577                    let resolved = field_resolver.resolve(field);
578                    let jq_path = field_to_jq_path(resolved);
579                    let threshold = json_value_to_shell_string(val);
580                    let safe_field = sanitize_ident(field);
581                    let _ = writeln!(out, "    local val_{safe_field}");
582                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
583                    let _ = writeln!(
584                        out,
585                        "    assert_less_than \"$val_{safe_field}\" '{threshold}' '{field}'"
586                    );
587                }
588            }
589        }
590        "not_contains" => {
591            if let Some(field) = &assertion.field {
592                if let Some(expected) = &assertion.value {
593                    let resolved = field_resolver.resolve(field);
594                    let jq_path = field_to_jq_path(resolved);
595                    let expected_str = json_value_to_shell_string(expected);
596                    let safe_field = sanitize_ident(field);
597                    let _ = writeln!(out, "    local val_{safe_field}");
598                    let _ = writeln!(out, "    val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
599                    let _ = writeln!(
600                        out,
601                        "    assert_not_contains \"$val_{safe_field}\" '{expected_str}' '{field}'"
602                    );
603                }
604            }
605        }
606        "not_error" => {
607            // No-op: reaching this point means the call succeeded.
608        }
609        "error" => {
610            // Handled at the function level (early return above).
611        }
612        other => {
613            let _ = writeln!(out, "    # TODO: unsupported assertion type: {other}");
614        }
615    }
616}
617
618/// Convert a JSON value to a plain string suitable for use in shell assertions.
619///
620/// Returns the bare string content (no quotes) — callers wrap in single quotes.
621fn json_value_to_shell_string(value: &serde_json::Value) -> String {
622    match value {
623        serde_json::Value::String(s) => escape_shell(s),
624        serde_json::Value::Bool(b) => b.to_string(),
625        serde_json::Value::Number(n) => n.to_string(),
626        serde_json::Value::Null => String::new(),
627        other => escape_shell(&other.to_string()),
628    }
629}