Skip to main content

alef_e2e/codegen/
zig.rs

1//! Zig e2e test generator using std.testing.
2//!
3//! Generates `packages/zig/src/<crate>_test.zig` files from JSON fixtures,
4//! driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_zig, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions::toolchain;
14use anyhow::Result;
15use heck::ToSnakeCase;
16use std::collections::HashSet;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23/// Zig e2e code generator.
24pub struct ZigE2eCodegen;
25
26impl E2eCodegen for ZigE2eCodegen {
27    fn generate(
28        &self,
29        groups: &[FixtureGroup],
30        e2e_config: &E2eConfig,
31        config: &ResolvedCrateConfig,
32        _type_defs: &[alef_core::ir::TypeDef],
33    ) -> Result<Vec<GeneratedFile>> {
34        let lang = self.language_name();
35        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37        let mut files = Vec::new();
38
39        // Resolve call config with overrides.
40        let call = &e2e_config.call;
41        let overrides = call.overrides.get(lang);
42        let _module_path = overrides
43            .and_then(|o| o.module.as_ref())
44            .cloned()
45            .unwrap_or_else(|| call.module.clone());
46        let function_name = overrides
47            .and_then(|o| o.function.as_ref())
48            .cloned()
49            .unwrap_or_else(|| call.function.clone());
50        let result_var = &call.result_var;
51
52        // Resolve package config.
53        let zig_pkg = e2e_config.resolve_package("zig");
54        let pkg_path = zig_pkg
55            .as_ref()
56            .and_then(|p| p.path.as_ref())
57            .cloned()
58            .unwrap_or_else(|| "../../packages/zig".to_string());
59        let pkg_name = zig_pkg
60            .as_ref()
61            .and_then(|p| p.name.as_ref())
62            .cloned()
63            .unwrap_or_else(|| config.name.to_snake_case());
64
65        // Generate build.zig.zon (Zig package manifest).
66        files.push(GeneratedFile {
67            path: output_base.join("build.zig.zon"),
68            content: render_build_zig_zon(&pkg_name, &pkg_path, e2e_config.dep_mode),
69            generated_header: false,
70        });
71
72        // Get the module name for imports.
73        let module_name = config.zig_module_name();
74
75        // Generate build.zig - collect test file names first.
76        let field_resolver = FieldResolver::new(
77            &e2e_config.fields,
78            &e2e_config.fields_optional,
79            &e2e_config.result_fields,
80            &e2e_config.fields_array,
81            &e2e_config.fields_method_calls,
82        );
83
84        // Generate test files per category and collect their names.
85        let mut test_filenames: Vec<String> = Vec::new();
86        for group in groups {
87            let active: Vec<&Fixture> = group
88                .fixtures
89                .iter()
90                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
91                .collect();
92
93            if active.is_empty() {
94                continue;
95            }
96
97            let filename = format!("{}_test.zig", sanitize_filename(&group.category));
98            test_filenames.push(filename.clone());
99            let content = render_test_file(
100                &group.category,
101                &active,
102                e2e_config,
103                &function_name,
104                result_var,
105                &e2e_config.call.args,
106                &field_resolver,
107                &e2e_config.fields_enum,
108                &module_name,
109            );
110            files.push(GeneratedFile {
111                path: output_base.join("src").join(filename),
112                content,
113                generated_header: true,
114            });
115        }
116
117        // Generate build.zig with collected test files.
118        files.insert(
119            files
120                .iter()
121                .position(|f| f.path.file_name().is_some_and(|n| n == "build.zig.zon"))
122                .unwrap_or(1),
123            GeneratedFile {
124                path: output_base.join("build.zig"),
125                content: render_build_zig(
126                    &test_filenames,
127                    &pkg_name,
128                    &module_name,
129                    &config.ffi_lib_name(),
130                    &config.ffi_crate_path(),
131                    &e2e_config.test_documents_relative_from(0),
132                ),
133                generated_header: false,
134            },
135        );
136
137        Ok(files)
138    }
139
140    fn language_name(&self) -> &'static str {
141        "zig"
142    }
143}
144
145// ---------------------------------------------------------------------------
146// Rendering
147// ---------------------------------------------------------------------------
148
149fn render_build_zig_zon(pkg_name: &str, pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
150    let dep_block = match dep_mode {
151        crate::config::DependencyMode::Registry => {
152            // For registry mode, use a dummy hash (in real Zig, hash must be computed).
153            format!(
154                r#".{{
155            .url = "https://registry.example.com/{pkg_name}/v0.1.0.tar.gz",
156            .hash = "0000000000000000000000000000000000000000000000000000000000000000",
157        }}"#
158            )
159        }
160        crate::config::DependencyMode::Local => {
161            format!(r#".{{ .path = "{pkg_path}" }}"#)
162        }
163    };
164
165    let min_zig = toolchain::MIN_ZIG_VERSION;
166    // Zig 0.16+ requires a fingerprint of the form (crc32_ieee(name) << 32) | id.
167    let name_bytes: &[u8] = b"e2e_zig";
168    let mut crc: u32 = 0xffff_ffff;
169    for byte in name_bytes {
170        crc ^= *byte as u32;
171        for _ in 0..8 {
172            let mask = (crc & 1).wrapping_neg();
173            crc = (crc >> 1) ^ (0xedb8_8320 & mask);
174        }
175    }
176    let name_crc: u32 = !crc;
177    let mut id: u32 = 0x811c_9dc5;
178    for byte in name_bytes {
179        id ^= *byte as u32;
180        id = id.wrapping_mul(0x0100_0193);
181    }
182    if id == 0 || id == 0xffff_ffff {
183        id = 0x1;
184    }
185    let fingerprint: u64 = ((name_crc as u64) << 32) | (id as u64);
186    format!(
187        r#".{{
188    .name = .e2e_zig,
189    .version = "0.1.0",
190    .fingerprint = 0x{fingerprint:016x},
191    .minimum_zig_version = "{min_zig}",
192    .dependencies = .{{
193        .{pkg_name} = {dep_block},
194    }},
195    .paths = .{{
196        "build.zig",
197        "build.zig.zon",
198        "src",
199    }},
200}}
201"#
202    )
203}
204
205fn render_build_zig(
206    test_filenames: &[String],
207    pkg_name: &str,
208    module_name: &str,
209    ffi_lib_name: &str,
210    ffi_crate_path: &str,
211    test_documents_path: &str,
212) -> String {
213    if test_filenames.is_empty() {
214        return r#"const std = @import("std");
215
216pub fn build(b: *std.Build) void {
217    const target = b.standardTargetOptions(.{});
218    const optimize = b.standardOptimizeOption(.{});
219
220    const test_step = b.step("test", "Run tests");
221}
222"#
223        .to_string();
224    }
225
226    // The Zig build script wires up three names that all derive from the
227    // crate config:
228    //   * `ffi_lib_name`     — the dynamic library to link (e.g. `mylib_ffi`).
229    //   * `pkg_name`         — the Zig package directory and source file stem
230    //                          under `packages/zig/src/<pkg_name>.zig`.
231    //   * `module_name`      — the Zig `@import("...")` identifier other test
232    //                          files use to import the binding module.
233    // Callers pass these in resolved form so this function never embeds a
234    // downstream crate's name.
235    let mut content = String::from("const std = @import(\"std\");\n\npub fn build(b: *std.Build) void {\n");
236    content.push_str("    const target = b.standardTargetOptions(.{});\n");
237    content.push_str("    const optimize = b.standardOptimizeOption(.{});\n");
238    content.push_str("    const test_step = b.step(\"test\", \"Run tests\");\n");
239    let _ = writeln!(
240        content,
241        "    const ffi_path = b.option([]const u8, \"ffi_path\", \"Path to directory containing lib{ffi_lib_name}\") orelse \"../../target/debug\";"
242    );
243    let _ = writeln!(
244        content,
245        "    const ffi_include = b.option([]const u8, \"ffi_include_path\", \"Path to directory containing FFI header\") orelse \"{ffi_crate_path}/include\";"
246    );
247    let _ = writeln!(content);
248    let _ = writeln!(
249        content,
250        "    const {module_name}_module = b.addModule(\"{module_name}\", .{{"
251    );
252    let _ = writeln!(
253        content,
254        "        .root_source_file = b.path(\"../../packages/zig/src/{pkg_name}.zig\"),"
255    );
256    content.push_str("        .target = target,\n");
257    content.push_str("        .optimize = optimize,\n");
258    content.push_str("    });\n");
259    let _ = writeln!(
260        content,
261        "    {module_name}_module.addLibraryPath(.{{ .cwd_relative = ffi_path }});"
262    );
263    let _ = writeln!(
264        content,
265        "    {module_name}_module.addIncludePath(.{{ .cwd_relative = ffi_include }});"
266    );
267    let _ = writeln!(
268        content,
269        "    {module_name}_module.linkSystemLibrary(\"{ffi_lib_name}\", .{{}});"
270    );
271    let _ = writeln!(content);
272
273    for filename in test_filenames {
274        // Convert filename like "basic_test.zig" to a test name
275        let test_name = filename.trim_end_matches("_test.zig");
276        content.push_str(&format!("    const {test_name}_module = b.createModule(.{{\n"));
277        content.push_str(&format!("        .root_source_file = b.path(\"src/{filename}\"),\n"));
278        content.push_str("        .target = target,\n");
279        content.push_str("        .optimize = optimize,\n");
280        content.push_str("    });\n");
281        content.push_str(&format!(
282            "    {test_name}_module.addImport(\"{module_name}\", {module_name}_module);\n"
283        ));
284        content.push_str(&format!("    const {test_name}_tests = b.addTest(.{{\n"));
285        content.push_str(&format!("        .root_module = {test_name}_module,\n"));
286        content.push_str("    });\n");
287        content.push_str(&format!(
288            "    const {test_name}_run = b.addRunArtifact({test_name}_tests);\n"
289        ));
290        content.push_str(&format!(
291            "    {test_name}_run.setCwd(b.path(\"{test_documents_path}\"));\n"
292        ));
293        content.push_str(&format!("    test_step.dependOn(&{test_name}_run.step);\n\n"));
294    }
295
296    content.push_str("}\n");
297    content
298}
299
300// ---------------------------------------------------------------------------
301// HTTP server test rendering — shared-driver integration
302// ---------------------------------------------------------------------------
303
304/// Renderer that emits Zig `test "..." { ... }` blocks targeting a mock server
305/// via `std.http.Client`. Satisfies [`client::TestClientRenderer`] so the shared
306/// [`client::http_call::render_http_test`] driver drives the call sequence.
307struct ZigTestClientRenderer;
308
309impl client::TestClientRenderer for ZigTestClientRenderer {
310    fn language_name(&self) -> &'static str {
311        "zig"
312    }
313
314    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
315        if let Some(reason) = skip_reason {
316            let _ = writeln!(out, "test \"{fn_name}\" {{");
317            let _ = writeln!(out, "    // {description}");
318            let _ = writeln!(out, "    // skipped: {reason}");
319            let _ = writeln!(out, "    return error.SkipZigTest;");
320        } else {
321            let _ = writeln!(out, "test \"{fn_name}\" {{");
322            let _ = writeln!(out, "    // {description}");
323        }
324    }
325
326    fn render_test_close(&self, out: &mut String) {
327        let _ = writeln!(out, "}}");
328    }
329
330    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
331        let method = ctx.method.to_uppercase();
332        let fixture_id = ctx.path.trim_start_matches("/fixtures/");
333
334        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
335        let _ = writeln!(out, "    defer _ = gpa.deinit();");
336        let _ = writeln!(out, "    const allocator = gpa.allocator();");
337
338        let _ = writeln!(out, "    var url_buf: [512]u8 = undefined;");
339        let _ = writeln!(
340            out,
341            "    const url = try std.fmt.bufPrint(&url_buf, \"{{s}}/fixtures/{fixture_id}\", .{{std.posix.getenv(\"MOCK_SERVER_URL\") orelse \"http://localhost:8080\"}});"
342        );
343
344        // Headers
345        if !ctx.headers.is_empty() {
346            let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
347            header_pairs.sort_by_key(|(k, _)| k.as_str());
348            let _ = writeln!(out, "    const headers = [_]std.http.Header{{");
349            for (k, v) in &header_pairs {
350                let ek = escape_zig(k);
351                let ev = escape_zig(v);
352                let _ = writeln!(out, "        .{{ .name = \"{ek}\", .value = \"{ev}\" }},");
353            }
354            let _ = writeln!(out, "    }};");
355        }
356
357        // Body
358        if let Some(body) = ctx.body {
359            let json_str = serde_json::to_string(body).unwrap_or_default();
360            let escaped = escape_zig(&json_str);
361            let _ = writeln!(out, "    const body_bytes: []const u8 = \"{escaped}\";");
362        }
363
364        let headers_arg = if ctx.headers.is_empty() { "&.{}" } else { "&headers" };
365        let has_body = ctx.body.is_some();
366
367        let _ = writeln!(
368            out,
369            "    var http_client = std.http.Client{{ .allocator = allocator }};"
370        );
371        let _ = writeln!(out, "    defer http_client.deinit();");
372        let _ = writeln!(out, "    var response_body = std.ArrayList(u8).init(allocator);");
373        let _ = writeln!(out, "    defer response_body.deinit();");
374
375        let method_zig = match method.as_str() {
376            "GET" => ".GET",
377            "POST" => ".POST",
378            "PUT" => ".PUT",
379            "DELETE" => ".DELETE",
380            "PATCH" => ".PATCH",
381            "HEAD" => ".HEAD",
382            "OPTIONS" => ".OPTIONS",
383            _ => ".GET",
384        };
385
386        let payload_field = if has_body { ", .payload = body_bytes" } else { "" };
387        let _ = writeln!(
388            out,
389            "    const {rv} = try http_client.fetch(.{{ .location = .{{ .url = url }}, .method = {method_zig}, .extra_headers = {headers_arg}{payload_field}, .response_storage = .{{ .dynamic = &response_body }} }});",
390            rv = ctx.response_var,
391        );
392    }
393
394    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
395        let _ = writeln!(
396            out,
397            "    try testing.expectEqual(@as(u10, {status}), @intFromEnum({response_var}.status));"
398        );
399    }
400
401    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
402        let ename = escape_zig(&name.to_lowercase());
403        match expected {
404            "<<present>>" => {
405                let _ = writeln!(
406                    out,
407                    "    // assert header '{ename}' is present (header inspection not yet implemented)"
408                );
409            }
410            "<<absent>>" => {
411                let _ = writeln!(
412                    out,
413                    "    // assert header '{ename}' is absent (header inspection not yet implemented)"
414                );
415            }
416            "<<uuid>>" => {
417                let _ = writeln!(
418                    out,
419                    "    // assert header '{ename}' matches UUID pattern (header inspection not yet implemented)"
420                );
421            }
422            exact => {
423                let evalue = escape_zig(exact);
424                let _ = writeln!(
425                    out,
426                    "    // assert header '{ename}' == \"{evalue}\" (header inspection not yet implemented)"
427                );
428            }
429        }
430    }
431
432    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
433        let json_str = serde_json::to_string(expected).unwrap_or_default();
434        let escaped = escape_zig(&json_str);
435        let _ = writeln!(
436            out,
437            "    try testing.expectEqualStrings(\"{escaped}\", response_body.items);"
438        );
439    }
440
441    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
442        if let Some(obj) = expected.as_object() {
443            for (key, val) in obj {
444                let ekey = escape_zig(key);
445                let eval = escape_zig(&serde_json::to_string(val).unwrap_or_default());
446                let _ = writeln!(
447                    out,
448                    "    // assert body contains field \"{ekey}\" = \"{eval}\" (partial JSON not yet implemented)"
449                );
450            }
451        }
452    }
453
454    fn render_assert_validation_errors(
455        &self,
456        out: &mut String,
457        _response_var: &str,
458        errors: &[crate::fixture::ValidationErrorExpectation],
459    ) {
460        for ve in errors {
461            let loc = ve.loc.join(".");
462            let escaped_loc = escape_zig(&loc);
463            let escaped_msg = escape_zig(&ve.msg);
464            let _ = writeln!(
465                out,
466                "    // assert validation error at \"{escaped_loc}\": \"{escaped_msg}\" (not yet implemented)"
467            );
468        }
469    }
470}
471
472/// Render a Zig `test "..." { ... }` block for an HTTP server fixture.
473///
474/// Delegates to the shared [`client::http_call::render_http_test`] driver via
475/// [`ZigTestClientRenderer`].
476fn render_http_test_case(out: &mut String, fixture: &Fixture) {
477    client::http_call::render_http_test(out, &ZigTestClientRenderer, fixture);
478}
479
480// ---------------------------------------------------------------------------
481// Function-call test rendering
482// ---------------------------------------------------------------------------
483
484#[allow(clippy::too_many_arguments)]
485fn render_test_file(
486    category: &str,
487    fixtures: &[&Fixture],
488    e2e_config: &E2eConfig,
489    function_name: &str,
490    result_var: &str,
491    args: &[crate::config::ArgMapping],
492    field_resolver: &FieldResolver,
493    enum_fields: &HashSet<String>,
494    module_name: &str,
495) -> String {
496    let mut out = String::new();
497    out.push_str(&hash::header(CommentStyle::DoubleSlash));
498    let _ = writeln!(out, "const std = @import(\"std\");");
499    let _ = writeln!(out, "const testing = std.testing;");
500    let _ = writeln!(out, "const {module_name} = @import(\"{module_name}\");");
501    let _ = writeln!(out);
502
503    let _ = writeln!(out, "// E2e tests for category: {category}");
504    let _ = writeln!(out);
505
506    for fixture in fixtures {
507        if fixture.http.is_some() {
508            render_http_test_case(&mut out, fixture);
509        } else {
510            render_test_fn(
511                &mut out,
512                fixture,
513                e2e_config,
514                function_name,
515                result_var,
516                args,
517                field_resolver,
518                enum_fields,
519                module_name,
520            );
521        }
522        let _ = writeln!(out);
523    }
524
525    out
526}
527
528#[allow(clippy::too_many_arguments)]
529fn render_test_fn(
530    out: &mut String,
531    fixture: &Fixture,
532    e2e_config: &E2eConfig,
533    _function_name: &str,
534    _result_var: &str,
535    _args: &[crate::config::ArgMapping],
536    field_resolver: &FieldResolver,
537    enum_fields: &HashSet<String>,
538    module_name: &str,
539) {
540    // Resolve per-fixture call config.
541    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
542    let lang = "zig";
543    let call_overrides = call_config.overrides.get(lang);
544    let function_name = call_overrides
545        .and_then(|o| o.function.as_ref())
546        .cloned()
547        .unwrap_or_else(|| call_config.function.clone());
548    let result_var = &call_config.result_var;
549    let args = &call_config.args;
550    let is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
551    // When `result_is_json_struct = true`, the Zig function returns `[]u8` JSON.
552    // The test parses it with `std.json.parseFromSlice(std.json.Value, ...)` and
553    // traverses the dynamic JSON object for field assertions.
554    let result_is_json_struct = call_overrides.is_some_and(|o| o.result_is_json_struct);
555
556    let test_name = fixture.id.to_snake_case();
557    let description = &fixture.description;
558    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
559
560    let (setup_lines, args_str, setup_needs_gpa) = build_args_and_setup(&fixture.input, args, &fixture.id, module_name);
561
562    // Pre-compute whether any assertion will emit code that references `result` /
563    // `allocator`. Used to decide whether to emit the GPA allocator binding.
564    let any_happy_emits_code = fixture
565        .assertions
566        .iter()
567        .any(|a| assertion_emits_code(a, field_resolver));
568    let any_non_error_emits_code = fixture
569        .assertions
570        .iter()
571        .filter(|a| a.assertion_type != "error")
572        .any(|a| assertion_emits_code(a, field_resolver));
573
574    let _ = writeln!(out, "test \"{test_name}\" {{");
575    let _ = writeln!(out, "    // {description}");
576
577    // Emit GPA allocator only when it will actually be used: setup lines that
578    // need GPA allocation (mock_url), or a JSON-struct result path where the test
579    // will call `std.json.parseFromSlice`. The binding is not needed for
580    // error-only paths or tests with no field assertions.
581    // Note: `bytes` arg setup uses c_allocator directly and does NOT require GPA.
582    let needs_gpa = setup_needs_gpa
583        || (result_is_json_struct && !expects_error && any_happy_emits_code)
584        || (result_is_json_struct && expects_error && any_non_error_emits_code);
585    if needs_gpa {
586        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
587        let _ = writeln!(out, "    defer _ = gpa.deinit();");
588        let _ = writeln!(out, "    const allocator = gpa.allocator();");
589        let _ = writeln!(out);
590    }
591
592    for line in &setup_lines {
593        let _ = writeln!(out, "    {line}");
594    }
595
596    if expects_error {
597        // Error-path test: use error union syntax `!T` and try-catch.
598        if is_async {
599            let _ = writeln!(
600                out,
601                "    // Note: async functions not yet fully supported; treating as sync"
602            );
603        }
604        if result_is_json_struct {
605            let _ = writeln!(
606                out,
607                "    const _result_json = {module_name}.{function_name}({args_str}) catch {{"
608            );
609        } else {
610            let _ = writeln!(
611                out,
612                "    const result = {module_name}.{function_name}({args_str}) catch {{"
613            );
614        }
615        let _ = writeln!(out, "        try testing.expect(true); // Error occurred as expected");
616        let _ = writeln!(out, "        return;");
617        let _ = writeln!(out, "    }};");
618        // Whether any non-error assertion will emit code that references `result`.
619        // If not, we must explicitly discard `result` to satisfy Zig's
620        // strict-unused-locals rule.
621        let any_emits_code = fixture
622            .assertions
623            .iter()
624            .filter(|a| a.assertion_type != "error")
625            .any(|a| assertion_emits_code(a, field_resolver));
626        if result_is_json_struct && any_emits_code {
627            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
628            let _ = writeln!(
629                out,
630                "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
631            );
632            let _ = writeln!(out, "    defer _parsed.deinit();");
633            let _ = writeln!(out, "    const {result_var} = &_parsed.value;");
634            let _ = writeln!(out, "    // Perform success assertions if any");
635            for assertion in &fixture.assertions {
636                if assertion.assertion_type != "error" {
637                    render_json_assertion(out, assertion, result_var);
638                }
639            }
640        } else if result_is_json_struct {
641            let _ = writeln!(out, "    _ = _result_json;");
642        } else if any_emits_code {
643            let _ = writeln!(out, "    // Perform success assertions if any");
644            for assertion in &fixture.assertions {
645                if assertion.assertion_type != "error" {
646                    render_assertion(out, assertion, result_var, field_resolver, enum_fields);
647                }
648            }
649        } else {
650            let _ = writeln!(out, "    _ = result;");
651        }
652    } else if fixture.assertions.is_empty() {
653        // No assertions: emit a call to verify compilation.
654        if is_async {
655            let _ = writeln!(
656                out,
657                "    // Note: async functions not yet fully supported; treating as sync"
658            );
659        }
660        if result_is_json_struct {
661            let _ = writeln!(
662                out,
663                "    const _result_json = try {module_name}.{function_name}({args_str});"
664            );
665            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
666        } else {
667            let _ = writeln!(out, "    _ = try {module_name}.{function_name}({args_str});");
668        }
669    } else {
670        // Happy path: call and assert. Detect whether any assertion actually
671        // emits code that references `result` (some — like `not_error` — emit
672        // nothing) so we don't leave an unused local, which Zig 0.16 rejects.
673        if is_async {
674            let _ = writeln!(
675                out,
676                "    // Note: async functions not yet fully supported; treating as sync"
677            );
678        }
679        let any_emits_code = fixture
680            .assertions
681            .iter()
682            .any(|a| assertion_emits_code(a, field_resolver));
683        if result_is_json_struct {
684            // JSON struct path: parse result JSON and access fields dynamically.
685            let _ = writeln!(
686                out,
687                "    const _result_json = try {module_name}.{function_name}({args_str});"
688            );
689            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
690            if any_emits_code {
691                let _ = writeln!(
692                    out,
693                    "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
694                );
695                let _ = writeln!(out, "    defer _parsed.deinit();");
696                let _ = writeln!(out, "    const {result_var} = &_parsed.value;");
697                for assertion in &fixture.assertions {
698                    render_json_assertion(out, assertion, result_var);
699                }
700            }
701        } else if any_emits_code {
702            let _ = writeln!(
703                out,
704                "    const {result_var} = try {module_name}.{function_name}({args_str});"
705            );
706            for assertion in &fixture.assertions {
707                render_assertion(out, assertion, result_var, field_resolver, enum_fields);
708            }
709        } else {
710            let _ = writeln!(out, "    _ = try {module_name}.{function_name}({args_str});");
711        }
712    }
713
714    let _ = writeln!(out, "}}");
715}
716
717// ---------------------------------------------------------------------------
718// JSON-struct assertion rendering (for result_is_json_struct = true)
719// ---------------------------------------------------------------------------
720
721/// Convert a dot-separated field path into a chain of `std.json.Value` lookups.
722///
723/// Each segment uses `.object.get("key").?` to traverse the JSON object tree.
724/// The final segment stops before the leaf-type accessor so callers can append
725/// the appropriate accessor (`.string`, `.integer`, `.array.items`, etc.).
726///
727/// Returns `(base_expr, last_key)` where `base_expr` already includes all
728/// intermediate `.object.get("…").?` dereferences up to (but not including)
729/// the leaf, and `last_key` is the last path segment.
730/// Variant names of `FormatMetadata` (snake_case, from `#[serde(rename_all = "snake_case")]`).
731/// These appear as typed accessors in fixture paths (e.g. `format.excel.sheet_count`)
732/// but are NOT JSON keys — `FormatMetadata` is internally tagged so variant fields are
733/// flattened directly into the `format` object alongside the `format_type` discriminant.
734const FORMAT_METADATA_VARIANTS: &[&str] = &[
735    "pdf",
736    "docx",
737    "excel",
738    "email",
739    "pptx",
740    "archive",
741    "image",
742    "xml",
743    "text",
744    "html",
745    "ocr",
746    "csv",
747    "bibtex",
748    "citation",
749    "fiction_book",
750    "dbf",
751    "jats",
752    "epub",
753    "pst",
754    "code",
755];
756
757fn json_path_expr(result_var: &str, field_path: &str) -> String {
758    let segments: Vec<&str> = field_path.split('.').collect();
759    let mut expr = result_var.to_string();
760    let mut prev_seg: Option<&str> = None;
761    for seg in &segments {
762        // Skip variant-name accessor segments that follow a `format` key.
763        // FormatMetadata is an internally-tagged enum (`#[serde(tag = "format_type")]`),
764        // so variant fields are flattened directly into the format object — there is no
765        // intermediate JSON key for the variant name.
766        if prev_seg == Some("format") && FORMAT_METADATA_VARIANTS.contains(seg) {
767            prev_seg = Some(seg);
768            continue;
769        }
770        expr = format!("{expr}.object.get(\"{seg}\").?");
771        prev_seg = Some(seg);
772    }
773    expr
774}
775
776/// Render a single assertion for a JSON-struct result (result_is_json_struct = true).
777///
778/// The `result_var` variable is `*std.json.Value` (pointer to the parsed root object).
779/// Field paths are traversed via `.object.get("key").?` chains.
780fn render_json_assertion(out: &mut String, assertion: &Assertion, result_var: &str) {
781    let field_path = assertion.field.as_deref().unwrap_or("").trim();
782
783    // Build the JSON traversal expression up to the leaf.
784    let field_expr = if field_path.is_empty() {
785        result_var.to_string()
786    } else {
787        json_path_expr(result_var, field_path)
788    };
789
790    match assertion.assertion_type.as_str() {
791        "equals" => {
792            if let Some(expected) = &assertion.value {
793                match expected {
794                    serde_json::Value::String(s) => {
795                        let zig_val = format!("\"{}\"", escape_zig(s));
796                        let _ = writeln!(
797                            out,
798                            "    try testing.expectEqualStrings({zig_val}, {field_expr}.string);"
799                        );
800                    }
801                    serde_json::Value::Bool(b) => {
802                        let _ = writeln!(out, "    try testing.expectEqual({b}, {field_expr}.bool);");
803                    }
804                    serde_json::Value::Number(n) => {
805                        let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.integer);");
806                    }
807                    _ => {}
808                }
809            }
810        }
811        "contains" => {
812            if let Some(serde_json::Value::String(s)) = &assertion.value {
813                let zig_val = format!("\"{}\"", escape_zig(s));
814                // Serialize the JSON value to a string and search.
815                // Works for both string fields (value is the string) and array/object
816                // fields (value is the JSON-encoded representation).
817                let _ = writeln!(out, "    {{");
818                let _ = writeln!(out, "        const _jv = {field_expr};");
819                let _ = writeln!(
820                    out,
821                    "        const _js = if (_jv == .string) _jv.string else try std.json.Stringify.valueAlloc(std.heap.c_allocator, _jv, .{{}});"
822                );
823                let _ = writeln!(out, "        defer if (_jv != .string) std.heap.c_allocator.free(_js);");
824                let _ = writeln!(
825                    out,
826                    "        try testing.expect(std.mem.indexOf(u8, _js, {zig_val}) != null);"
827                );
828                let _ = writeln!(out, "    }}");
829            }
830        }
831        "contains_all" => {
832            // For string fields: search the string value. For array/object fields:
833            // serialize to JSON and search the JSON text (e.g., ["a","b"] contains "b").
834            if let Some(values) = &assertion.values {
835                for (idx, val) in values.iter().enumerate() {
836                    if let serde_json::Value::String(s) = val {
837                        let zig_val = format!("\"{}\"", escape_zig(s));
838                        let jv = format!("_jva{idx}");
839                        let js = format!("_jsa{idx}");
840                        let _ = writeln!(out, "    {{");
841                        let _ = writeln!(out, "        const {jv} = {field_expr};");
842                        let _ = writeln!(
843                            out,
844                            "        const {js} = if ({jv} == .string) {jv}.string else try std.json.Stringify.valueAlloc(std.heap.c_allocator, {jv}, .{{}});"
845                        );
846                        let _ = writeln!(
847                            out,
848                            "        defer if ({jv} != .string) std.heap.c_allocator.free({js});"
849                        );
850                        let _ = writeln!(
851                            out,
852                            "        try testing.expect(std.mem.indexOf(u8, {js}, {zig_val}) != null);"
853                        );
854                        let _ = writeln!(out, "    }}");
855                    }
856                }
857            }
858        }
859        "not_contains" => {
860            if let Some(serde_json::Value::String(s)) = &assertion.value {
861                let zig_val = format!("\"{}\"", escape_zig(s));
862                let _ = writeln!(out, "    {{");
863                let _ = writeln!(out, "        const _jvnc = {field_expr};");
864                let _ = writeln!(
865                    out,
866                    "        const _jsnc = if (_jvnc == .string) _jvnc.string else try std.json.Stringify.valueAlloc(std.heap.c_allocator, _jvnc, .{{}});"
867                );
868                let _ = writeln!(
869                    out,
870                    "        defer if (_jvnc != .string) std.heap.c_allocator.free(_jsnc);"
871                );
872                let _ = writeln!(
873                    out,
874                    "        try testing.expect(std.mem.indexOf(u8, _jsnc, {zig_val}) == null);"
875                );
876                let _ = writeln!(out, "    }}");
877            }
878        }
879        "not_empty" => {
880            // For a JSON object field: check it's present and non-null.
881            // For a string field: check length > 0.
882            // We emit a check that the field is not a JSON null.
883            let _ = writeln!(out, "    try testing.expect({field_expr} != .null);");
884        }
885        "is_empty" => {
886            let _ = writeln!(out, "    try testing.expectEqual(.null, {field_expr});");
887        }
888        "min_length" => {
889            if let Some(val) = &assertion.value {
890                if let Some(n) = val.as_u64() {
891                    let _ = writeln!(out, "    try testing.expect({field_expr}.string.len >= {n});");
892                }
893            }
894        }
895        "count_min" => {
896            if let Some(val) = &assertion.value {
897                if let Some(n) = val.as_u64() {
898                    let _ = writeln!(out, "    try testing.expect({field_expr}.array.items.len >= {n});");
899                }
900            }
901        }
902        "count_equals" => {
903            if let Some(val) = &assertion.value {
904                if let Some(n) = val.as_u64() {
905                    let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.array.items.len);");
906                }
907            }
908        }
909        "greater_than" => {
910            if let Some(val) = &assertion.value {
911                let n = json_to_zig(val);
912                let _ = writeln!(out, "    try testing.expect({field_expr}.integer > {n});");
913            }
914        }
915        "less_than" => {
916            if let Some(val) = &assertion.value {
917                let n = json_to_zig(val);
918                let _ = writeln!(out, "    try testing.expect({field_expr}.integer < {n});");
919            }
920        }
921        "greater_than_or_equal" => {
922            if let Some(val) = &assertion.value {
923                let n = json_to_zig(val);
924                let _ = writeln!(out, "    try testing.expect({field_expr}.integer >= {n});");
925            }
926        }
927        "less_than_or_equal" => {
928            if let Some(val) = &assertion.value {
929                let n = json_to_zig(val);
930                let _ = writeln!(out, "    try testing.expect({field_expr}.integer <= {n});");
931            }
932        }
933        "is_true" => {
934            let _ = writeln!(out, "    try testing.expect({field_expr}.bool);");
935        }
936        "is_false" => {
937            let _ = writeln!(out, "    try testing.expect(!{field_expr}.bool);");
938        }
939        "not_error" | "error" => {
940            // Handled at the call level.
941        }
942        "starts_with" => {
943            if let Some(serde_json::Value::String(s)) = &assertion.value {
944                let zig_val = format!("\"{}\"", escape_zig(s));
945                let _ = writeln!(
946                    out,
947                    "    try testing.expect(std.mem.startsWith(u8, {field_expr}.string, {zig_val}));"
948                );
949            }
950        }
951        "ends_with" => {
952            if let Some(serde_json::Value::String(s)) = &assertion.value {
953                let zig_val = format!("\"{}\"", escape_zig(s));
954                let _ = writeln!(
955                    out,
956                    "    try testing.expect(std.mem.endsWith(u8, {field_expr}.string, {zig_val}));"
957                );
958            }
959        }
960        "contains_any" => {
961            // At least ONE of the values must be found in the field (OR logic).
962            if let Some(values) = &assertion.values {
963                let string_values: Vec<String> = values
964                    .iter()
965                    .filter_map(|v| {
966                        if let serde_json::Value::String(s) = v {
967                            Some(format!(
968                                "std.mem.indexOf(u8, {field_expr}.string, \"{}\") != null",
969                                escape_zig(s)
970                            ))
971                        } else {
972                            None
973                        }
974                    })
975                    .collect();
976                if !string_values.is_empty() {
977                    let condition = string_values.join(" or\n        ");
978                    let _ = writeln!(out, "    try testing.expect(\n        {condition}\n    );");
979                }
980            }
981        }
982        other => {
983            let _ = writeln!(out, "    // json assertion '{other}' not implemented for Zig");
984        }
985    }
986}
987
988/// Predicate matching `render_assertion`: returns true when the assertion
989/// would emit at least one statement that references the result variable.
990fn assertion_emits_code(assertion: &Assertion, field_resolver: &FieldResolver) -> bool {
991    if let Some(f) = &assertion.field {
992        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
993            return false;
994        }
995    }
996    matches!(
997        assertion.assertion_type.as_str(),
998        "equals"
999            | "contains"
1000            | "contains_all"
1001            | "not_contains"
1002            | "not_empty"
1003            | "is_empty"
1004            | "starts_with"
1005            | "ends_with"
1006            | "min_length"
1007            | "max_length"
1008            | "count_min"
1009            | "count_equals"
1010            | "is_true"
1011            | "is_false"
1012            | "greater_than"
1013            | "less_than"
1014            | "greater_than_or_equal"
1015            | "less_than_or_equal"
1016            | "contains_any"
1017    )
1018}
1019
1020/// Build setup lines and the argument list for the function call.
1021///
1022/// Returns `(setup_lines, args_str, setup_needs_gpa)` where `setup_needs_gpa`
1023/// is `true` when at least one setup line requires the GPA `allocator` binding.
1024fn build_args_and_setup(
1025    input: &serde_json::Value,
1026    args: &[crate::config::ArgMapping],
1027    fixture_id: &str,
1028    _module_name: &str,
1029) -> (Vec<String>, String, bool) {
1030    if args.is_empty() {
1031        return (Vec::new(), String::new(), false);
1032    }
1033
1034    let mut setup_lines: Vec<String> = Vec::new();
1035    let mut parts: Vec<String> = Vec::new();
1036    let mut setup_needs_gpa = false;
1037
1038    for arg in args {
1039        if arg.arg_type == "mock_url" {
1040            setup_lines.push(format!(
1041                "var {} = try allocator.alloc(u8, std.fmt.bufPrint(undefined, \"{{s}}/fixtures/{fixture_id}\", .{{std.posix.getenv(\"MOCK_SERVER_URL\") orelse \"http://localhost:8080\"}}) catch 0)",
1042                arg.name,
1043            ));
1044            parts.push(arg.name.clone());
1045            setup_needs_gpa = true;
1046            continue;
1047        }
1048
1049        // The Zig wrapper accepts struct parameters (e.g. `ExtractionConfig`)
1050        // as JSON `[]const u8`, converting them to opaque FFI handles via the
1051        // `<prefix>_<snake>_from_json` helper at the binding layer. Emit the
1052        // fixture's configuration value as a JSON string literal, falling back
1053        // to `"{}"` when the fixture omits a config so callers exercise the
1054        // default path.
1055        if arg.name == "config" && arg.arg_type == "json_object" {
1056            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1057            let json_str = match input.get(field) {
1058                Some(serde_json::Value::Null) | None => "{}".to_string(),
1059                Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1060            };
1061            parts.push(format!("\"{}\"", escape_zig(&json_str)));
1062            continue;
1063        }
1064
1065        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1066        let val = input.get(field);
1067        match val {
1068            None | Some(serde_json::Value::Null) if arg.optional => {
1069                // Zig functions don't have default arguments, so we must
1070                // pass `null` explicitly for every optional parameter.
1071                parts.push("null".to_string());
1072            }
1073            None | Some(serde_json::Value::Null) => {
1074                let default_val = match arg.arg_type.as_str() {
1075                    "string" => "\"\"".to_string(),
1076                    "int" | "integer" => "0".to_string(),
1077                    "float" | "number" => "0.0".to_string(),
1078                    "bool" | "boolean" => "false".to_string(),
1079                    "json_object" => "\"{}\"".to_string(),
1080                    _ => "null".to_string(),
1081                };
1082                parts.push(default_val);
1083            }
1084            Some(v) => {
1085                // For `json_object` arguments other than `config` (handled
1086                // above) the Zig binding accepts a JSON `[]const u8`, so we
1087                // serialize the entire fixture value as a single JSON string
1088                // literal rather than rendering it as a Zig array/struct.
1089                if arg.arg_type == "json_object" {
1090                    let json_str = serde_json::to_string(v).unwrap_or_default();
1091                    parts.push(format!("\"{}\"", escape_zig(&json_str)));
1092                } else if arg.arg_type == "bytes" {
1093                    // `bytes` args are file paths in fixtures — read the file into a
1094                    // local buffer. The cwd is set to test_documents/ at runtime.
1095                    // Zig 0.16 uses std.Io.Dir.cwd() (not std.fs.cwd()) and requires
1096                    // an `io` instance from std.testing.io in test context.
1097                    if let serde_json::Value::String(path) = v {
1098                        let var_name = format!("{}_bytes", arg.name);
1099                        let epath = escape_zig(path);
1100                        setup_lines.push(format!(
1101                            "const {var_name} = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, \"{epath}\", std.heap.c_allocator, .unlimited);"
1102                        ));
1103                        setup_lines.push(format!("defer std.heap.c_allocator.free({var_name});"));
1104                        parts.push(var_name);
1105                    } else {
1106                        parts.push(json_to_zig(v));
1107                    }
1108                } else {
1109                    parts.push(json_to_zig(v));
1110                }
1111            }
1112        }
1113    }
1114
1115    (setup_lines, parts.join(", "), setup_needs_gpa)
1116}
1117
1118fn render_assertion(
1119    out: &mut String,
1120    assertion: &Assertion,
1121    result_var: &str,
1122    field_resolver: &FieldResolver,
1123    enum_fields: &HashSet<String>,
1124) {
1125    // Skip assertions on fields that don't exist on the result type.
1126    if let Some(f) = &assertion.field {
1127        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1128            let _ = writeln!(out, "    // skipped: field '{{f}}' not available on result type");
1129            return;
1130        }
1131    }
1132
1133    // Determine if this field is an enum type.
1134    let _field_is_enum = assertion
1135        .field
1136        .as_deref()
1137        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1138
1139    let field_expr = match &assertion.field {
1140        Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
1141        _ => result_var.to_string(),
1142    };
1143
1144    match assertion.assertion_type.as_str() {
1145        "equals" => {
1146            if let Some(expected) = &assertion.value {
1147                let zig_val = json_to_zig(expected);
1148                let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {field_expr});");
1149            }
1150        }
1151        "contains" => {
1152            if let Some(expected) = &assertion.value {
1153                let zig_val = json_to_zig(expected);
1154                let _ = writeln!(
1155                    out,
1156                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1157                );
1158            }
1159        }
1160        "contains_all" => {
1161            if let Some(values) = &assertion.values {
1162                for val in values {
1163                    let zig_val = json_to_zig(val);
1164                    let _ = writeln!(
1165                        out,
1166                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1167                    );
1168                }
1169            }
1170        }
1171        "not_contains" => {
1172            if let Some(expected) = &assertion.value {
1173                let zig_val = json_to_zig(expected);
1174                let _ = writeln!(
1175                    out,
1176                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1177                );
1178            }
1179        }
1180        "not_empty" => {
1181            let _ = writeln!(out, "    try testing.expect({field_expr}.len > 0);");
1182        }
1183        "is_empty" => {
1184            let _ = writeln!(out, "    try testing.expect({field_expr}.len == 0);");
1185        }
1186        "starts_with" => {
1187            if let Some(expected) = &assertion.value {
1188                let zig_val = json_to_zig(expected);
1189                let _ = writeln!(
1190                    out,
1191                    "    try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
1192                );
1193            }
1194        }
1195        "ends_with" => {
1196            if let Some(expected) = &assertion.value {
1197                let zig_val = json_to_zig(expected);
1198                let _ = writeln!(
1199                    out,
1200                    "    try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
1201                );
1202            }
1203        }
1204        "min_length" => {
1205            if let Some(val) = &assertion.value {
1206                if let Some(n) = val.as_u64() {
1207                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
1208                }
1209            }
1210        }
1211        "max_length" => {
1212            if let Some(val) = &assertion.value {
1213                if let Some(n) = val.as_u64() {
1214                    let _ = writeln!(out, "    try testing.expect({field_expr}.len <= {n});");
1215                }
1216            }
1217        }
1218        "count_min" => {
1219            if let Some(val) = &assertion.value {
1220                if let Some(n) = val.as_u64() {
1221                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
1222                }
1223            }
1224        }
1225        "count_equals" => {
1226            if let Some(val) = &assertion.value {
1227                if let Some(n) = val.as_u64() {
1228                    // When there is no field (field_expr == result_var), the result
1229                    // is `[]u8` JSON (e.g. batch functions). Parse the JSON array
1230                    // and count its elements; `.len` would give byte count, not item count.
1231                    let has_field = assertion.field.as_deref().is_some_and(|f| !f.is_empty());
1232                    if has_field {
1233                        let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.len);");
1234                    } else {
1235                        let _ = writeln!(out, "    {{");
1236                        let _ = writeln!(
1237                            out,
1238                            "        var _cparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {field_expr}, .{{}});"
1239                        );
1240                        let _ = writeln!(out, "        defer _cparse.deinit();");
1241                        let _ = writeln!(
1242                            out,
1243                            "        try testing.expectEqual({n}, _cparse.value.array.items.len);"
1244                        );
1245                        let _ = writeln!(out, "    }}");
1246                    }
1247                }
1248            }
1249        }
1250        "is_true" => {
1251            let _ = writeln!(out, "    try testing.expect({field_expr});");
1252        }
1253        "is_false" => {
1254            let _ = writeln!(out, "    try testing.expect(!{field_expr});");
1255        }
1256        "not_error" => {
1257            // Already handled by the call succeeding.
1258        }
1259        "error" => {
1260            // Handled at the test function level.
1261        }
1262        "greater_than" => {
1263            if let Some(val) = &assertion.value {
1264                let zig_val = json_to_zig(val);
1265                let _ = writeln!(out, "    try testing.expect({field_expr} > {zig_val});");
1266            }
1267        }
1268        "less_than" => {
1269            if let Some(val) = &assertion.value {
1270                let zig_val = json_to_zig(val);
1271                let _ = writeln!(out, "    try testing.expect({field_expr} < {zig_val});");
1272            }
1273        }
1274        "greater_than_or_equal" => {
1275            if let Some(val) = &assertion.value {
1276                let zig_val = json_to_zig(val);
1277                let _ = writeln!(out, "    try testing.expect({field_expr} >= {zig_val});");
1278            }
1279        }
1280        "less_than_or_equal" => {
1281            if let Some(val) = &assertion.value {
1282                let zig_val = json_to_zig(val);
1283                let _ = writeln!(out, "    try testing.expect({field_expr} <= {zig_val});");
1284            }
1285        }
1286        "contains_any" => {
1287            // At least ONE of the values must be found in the field (OR logic).
1288            if let Some(values) = &assertion.values {
1289                let string_values: Vec<String> = values
1290                    .iter()
1291                    .filter_map(|v| {
1292                        if let serde_json::Value::String(s) = v {
1293                            Some(format!(
1294                                "std.mem.indexOf(u8, {field_expr}, \"{}\") != null",
1295                                escape_zig(s)
1296                            ))
1297                        } else {
1298                            None
1299                        }
1300                    })
1301                    .collect();
1302                if !string_values.is_empty() {
1303                    let condition = string_values.join(" or\n        ");
1304                    let _ = writeln!(out, "    try testing.expect(\n        {condition}\n    );");
1305                }
1306            }
1307        }
1308        "matches_regex" => {
1309            let _ = writeln!(out, "    // regex match not yet implemented for Zig");
1310        }
1311        "method_result" => {
1312            let _ = writeln!(out, "    // method_result assertions not yet implemented for Zig");
1313        }
1314        other => {
1315            panic!("Zig e2e generator: unsupported assertion type: {other}");
1316        }
1317    }
1318}
1319
1320/// Convert a `serde_json::Value` to a Zig literal string.
1321fn json_to_zig(value: &serde_json::Value) -> String {
1322    match value {
1323        serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
1324        serde_json::Value::Bool(b) => b.to_string(),
1325        serde_json::Value::Number(n) => n.to_string(),
1326        serde_json::Value::Null => "null".to_string(),
1327        serde_json::Value::Array(arr) => {
1328            let items: Vec<String> = arr.iter().map(json_to_zig).collect();
1329            format!("&.{{{}}}", items.join(", "))
1330        }
1331        serde_json::Value::Object(_) => {
1332            let json_str = serde_json::to_string(value).unwrap_or_default();
1333            format!("\"{}\"", escape_zig(&json_str))
1334        }
1335    }
1336}