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::{ToShoutySnakeCase, ToSnakeCase};
16use std::collections::HashSet;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22use super::streaming_assertions::{StreamingFieldResolver, is_streaming_virtual_field};
23
24/// Zig e2e code generator.
25pub struct ZigE2eCodegen;
26
27impl E2eCodegen for ZigE2eCodegen {
28    fn generate(
29        &self,
30        groups: &[FixtureGroup],
31        e2e_config: &E2eConfig,
32        config: &ResolvedCrateConfig,
33        _type_defs: &[alef_core::ir::TypeDef],
34        _enums: &[alef_core::ir::EnumDef],
35    ) -> Result<Vec<GeneratedFile>> {
36        let lang = self.language_name();
37        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
38
39        let mut files = Vec::new();
40
41        // Resolve call config with overrides.
42        let call = &e2e_config.call;
43        let overrides = call.overrides.get(lang);
44        let _module_path = overrides
45            .and_then(|o| o.module.as_ref())
46            .cloned()
47            .unwrap_or_else(|| call.module.clone());
48        let function_name = overrides
49            .and_then(|o| o.function.as_ref())
50            .cloned()
51            .unwrap_or_else(|| call.function.clone());
52        let result_var = &call.result_var;
53
54        // Resolve package config.
55        let zig_pkg = e2e_config.resolve_package("zig");
56        let pkg_path = zig_pkg
57            .as_ref()
58            .and_then(|p| p.path.as_ref())
59            .cloned()
60            .unwrap_or_else(|| "../../packages/zig".to_string());
61        let pkg_name = zig_pkg
62            .as_ref()
63            .and_then(|p| p.name.as_ref())
64            .cloned()
65            .unwrap_or_else(|| config.name.to_snake_case());
66
67        // Generate build.zig.zon (Zig package manifest).
68        files.push(GeneratedFile {
69            path: output_base.join("build.zig.zon"),
70            content: render_build_zig_zon(&pkg_name, &pkg_path, e2e_config.dep_mode),
71            generated_header: false,
72        });
73
74        // Get the module name for imports.
75        let module_name = config.zig_module_name();
76        let ffi_prefix = config.ffi_prefix();
77
78        // Generate build.zig - collect test file names first.
79        let field_resolver = FieldResolver::new(
80            &e2e_config.fields,
81            &e2e_config.fields_optional,
82            &e2e_config.result_fields,
83            &e2e_config.fields_array,
84            &e2e_config.fields_method_calls,
85        );
86
87        // Generate test files per category and collect their names.
88        let mut test_filenames: Vec<String> = Vec::new();
89        for group in groups {
90            let active: Vec<&Fixture> = group
91                .fixtures
92                .iter()
93                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
94                .collect();
95
96            if active.is_empty() {
97                continue;
98            }
99
100            let filename = format!("{}_test.zig", sanitize_filename(&group.category));
101            test_filenames.push(filename.clone());
102            let content = render_test_file(
103                &group.category,
104                &active,
105                e2e_config,
106                &function_name,
107                result_var,
108                &e2e_config.call.args,
109                &field_resolver,
110                &e2e_config.fields_enum,
111                &module_name,
112                &ffi_prefix,
113            );
114            files.push(GeneratedFile {
115                path: output_base.join("src").join(filename),
116                content,
117                generated_header: true,
118            });
119        }
120
121        // Generate build.zig with collected test files.
122        files.insert(
123            files
124                .iter()
125                .position(|f| f.path.file_name().is_some_and(|n| n == "build.zig.zon"))
126                .unwrap_or(1),
127            GeneratedFile {
128                path: output_base.join("build.zig"),
129                content: render_build_zig(
130                    &test_filenames,
131                    &pkg_name,
132                    &module_name,
133                    &config.ffi_lib_name(),
134                    &config.ffi_crate_path(),
135                    &e2e_config.test_documents_relative_from(0),
136                ),
137                generated_header: false,
138            },
139        );
140
141        Ok(files)
142    }
143
144    fn language_name(&self) -> &'static str {
145        "zig"
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Rendering
151// ---------------------------------------------------------------------------
152
153fn render_build_zig_zon(pkg_name: &str, pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
154    let dep_block = match dep_mode {
155        crate::config::DependencyMode::Registry => {
156            // For registry mode, use a dummy hash (in real Zig, hash must be computed).
157            format!(
158                r#".{{
159            .url = "https://registry.example.com/{pkg_name}/v0.1.0.tar.gz",
160            .hash = "0000000000000000000000000000000000000000000000000000000000000000",
161        }}"#
162            )
163        }
164        crate::config::DependencyMode::Local => {
165            format!(r#".{{ .path = "{pkg_path}" }}"#)
166        }
167    };
168
169    let min_zig = toolchain::MIN_ZIG_VERSION;
170    // Zig 0.16+ requires a fingerprint of the form (crc32_ieee(name) << 32) | id.
171    let name_bytes: &[u8] = b"e2e_zig";
172    let mut crc: u32 = 0xffff_ffff;
173    for byte in name_bytes {
174        crc ^= *byte as u32;
175        for _ in 0..8 {
176            let mask = (crc & 1).wrapping_neg();
177            crc = (crc >> 1) ^ (0xedb8_8320 & mask);
178        }
179    }
180    let name_crc: u32 = !crc;
181    let mut id: u32 = 0x811c_9dc5;
182    for byte in name_bytes {
183        id ^= *byte as u32;
184        id = id.wrapping_mul(0x0100_0193);
185    }
186    if id == 0 || id == 0xffff_ffff {
187        id = 0x1;
188    }
189    let fingerprint: u64 = ((name_crc as u64) << 32) | (id as u64);
190    format!(
191        r#".{{
192    .name = .e2e_zig,
193    .version = "0.1.0",
194    .fingerprint = 0x{fingerprint:016x},
195    .minimum_zig_version = "{min_zig}",
196    .dependencies = .{{
197        .{pkg_name} = {dep_block},
198    }},
199    .paths = .{{
200        "build.zig",
201        "build.zig.zon",
202        "src",
203    }},
204}}
205"#
206    )
207}
208
209fn render_build_zig(
210    test_filenames: &[String],
211    pkg_name: &str,
212    module_name: &str,
213    ffi_lib_name: &str,
214    ffi_crate_path: &str,
215    test_documents_path: &str,
216) -> String {
217    if test_filenames.is_empty() {
218        return r#"const std = @import("std");
219
220pub fn build(b: *std.Build) void {
221    const target = b.standardTargetOptions(.{});
222    const optimize = b.standardOptimizeOption(.{});
223
224    const test_step = b.step("test", "Run tests");
225}
226"#
227        .to_string();
228    }
229
230    // The Zig build script wires up three names that all derive from the
231    // crate config:
232    //   * `ffi_lib_name`     — the dynamic library to link (e.g. `mylib_ffi`).
233    //   * `pkg_name`         — the Zig package directory and source file stem
234    //                          under `packages/zig/src/<pkg_name>.zig`.
235    //   * `module_name`      — the Zig `@import("...")` identifier other test
236    //                          files use to import the binding module.
237    // Callers pass these in resolved form so this function never embeds a
238    // downstream crate's name.
239    let mut content = String::from("const std = @import(\"std\");\n\npub fn build(b: *std.Build) void {\n");
240    content.push_str("    const target = b.standardTargetOptions(.{});\n");
241    content.push_str("    const optimize = b.standardOptimizeOption(.{});\n");
242    content.push_str("    const test_step = b.step(\"test\", \"Run tests\");\n");
243    let _ = writeln!(
244        content,
245        "    const ffi_path = b.option([]const u8, \"ffi_path\", \"Path to directory containing lib{ffi_lib_name}\") orelse \"../../target/debug\";"
246    );
247    let _ = writeln!(
248        content,
249        "    const ffi_include = b.option([]const u8, \"ffi_include_path\", \"Path to directory containing FFI header\") orelse \"{ffi_crate_path}/include\";"
250    );
251    let _ = writeln!(content);
252    let _ = writeln!(
253        content,
254        "    const {module_name}_module = b.addModule(\"{module_name}\", .{{"
255    );
256    let _ = writeln!(
257        content,
258        "        .root_source_file = b.path(\"../../packages/zig/src/{pkg_name}.zig\"),"
259    );
260    content.push_str("        .target = target,\n");
261    content.push_str("        .optimize = optimize,\n");
262    // Zig 0.16 requires explicit libc linking for any module that transitively
263    // references stdlib C bindings (e.g. `c.getenv` via std.posix). The shared
264    // binding module pulls in the FFI header, so libc is always required.
265    content.push_str("        .link_libc = true,\n");
266    content.push_str("    });\n");
267    let _ = writeln!(
268        content,
269        "    {module_name}_module.addLibraryPath(.{{ .cwd_relative = ffi_path }});"
270    );
271    let _ = writeln!(
272        content,
273        "    {module_name}_module.addIncludePath(.{{ .cwd_relative = ffi_include }});"
274    );
275    let _ = writeln!(
276        content,
277        "    {module_name}_module.linkSystemLibrary(\"{ffi_lib_name}\", .{{}});"
278    );
279    let _ = writeln!(content);
280
281    for filename in test_filenames {
282        // Convert filename like "basic_test.zig" to a test name
283        let test_name = filename.trim_end_matches("_test.zig");
284        content.push_str(&format!("    const {test_name}_module = b.createModule(.{{\n"));
285        content.push_str(&format!("        .root_source_file = b.path(\"src/{filename}\"),\n"));
286        content.push_str("        .target = target,\n");
287        content.push_str("        .optimize = optimize,\n");
288        // Each test module also needs libc linking because it imports the binding
289        // module (which references C stdlib symbols) and may directly call helpers
290        // like `std.c.getenv` for env-var-driven mock-server URLs.
291        content.push_str("        .link_libc = true,\n");
292        content.push_str("    });\n");
293        content.push_str(&format!(
294            "    {test_name}_module.addImport(\"{module_name}\", {module_name}_module);\n"
295        ));
296        // Zig 0.16: addTest hashes its output binary path off the artifact `.name`.
297        // Without an explicit name, every addTest call defaults to "test", colliding
298        // in the cache — only one binary survives, every other addRunArtifact fails
299        // with FileNotFound at its computed path. Setting a unique name per test
300        // module produces a distinct .zig-cache/o/<hash>/<name> binary for each.
301        //
302        // Zig 0.16 ALSO defaults to the self-hosted backend on aarch64-linux for
303        // Debug builds. That backend emits the test binary at a different cache
304        // path (or with different permissions) than the build system's RunStep
305        // computes when reading `getEmittedBin()`, so every `addRunArtifact` call
306        // fails with `FileNotFound` at `.zig-cache/o/<hash>/<name>` even though
307        // the compile step reports success. Forcing `.use_llvm = true` pins the
308        // LLVM backend, which keeps the emitted binary at the path the RunStep
309        // expects. Other Zig backends (x86_64 macOS/Linux) already default to
310        // LLVM, so this is a no-op there.
311        content.push_str(&format!("    const {test_name}_tests = b.addTest(.{{\n"));
312        content.push_str(&format!("        .name = \"{test_name}_test\",\n"));
313        content.push_str(&format!("        .root_module = {test_name}_module,\n"));
314        content.push_str("        .use_llvm = true,\n");
315        content.push_str("    });\n");
316        // Run the test binary via `addRunArtifact` and point its working
317        // directory at the repo-root `test_documents/` so generated tests can
318        // resolve fixture paths like `pdf/fake_memo.pdf` via plain
319        // `std.Io.Dir.cwd().readFileAlloc(...)`. Other languages perform this
320        // chdir in a per-suite hook (Go `TestMain`, Python conftest, Kotlin
321        // Gradle `workingDir`); Zig has no equivalent test-suite init hook, so
322        // it must happen at the build-step level.
323        //
324        // `.use_llvm = true` above ensures the test binary is emitted at the
325        // path `Run.make` expects, so `getEmittedBin()` resolves to an
326        // absolute cache path before `convertPathArg` runs — `setCwd` on the
327        // run step then only affects the *child process* cwd, not how Zig
328        // looks up the binary to spawn. This combination keeps the binary
329        // findable and lets generated test bodies use fixture-relative paths.
330        content.push_str(&format!(
331            "    const {test_name}_run = b.addRunArtifact({test_name}_tests);\n"
332        ));
333        content.push_str(&format!(
334            "    {test_name}_run.setCwd(b.path(\"{test_documents_path}\"));\n"
335        ));
336        content.push_str(&format!("    test_step.dependOn(&{test_name}_run.step);\n\n"));
337    }
338
339    content.push_str("}\n");
340    content
341}
342
343// ---------------------------------------------------------------------------
344// HTTP server test rendering — shared-driver integration
345// ---------------------------------------------------------------------------
346
347/// Renderer that emits Zig `test "..." { ... }` blocks targeting a mock server
348/// via `std.http.Client`. Satisfies [`client::TestClientRenderer`] so the shared
349/// [`client::http_call::render_http_test`] driver drives the call sequence.
350struct ZigTestClientRenderer;
351
352impl client::TestClientRenderer for ZigTestClientRenderer {
353    fn language_name(&self) -> &'static str {
354        "zig"
355    }
356
357    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
358        if let Some(reason) = skip_reason {
359            let _ = writeln!(out, "test \"{fn_name}\" {{");
360            let _ = writeln!(out, "    // {description}");
361            let _ = writeln!(out, "    // skipped: {reason}");
362            let _ = writeln!(out, "    return error.SkipZigTest;");
363        } else {
364            let _ = writeln!(out, "test \"{fn_name}\" {{");
365            let _ = writeln!(out, "    // {description}");
366        }
367    }
368
369    fn render_test_close(&self, out: &mut String) {
370        let _ = writeln!(out, "}}");
371    }
372
373    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
374        let method = ctx.method.to_uppercase();
375        let fixture_id = ctx.path.trim_start_matches("/fixtures/");
376
377        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
378        let _ = writeln!(out, "    defer _ = gpa.deinit();");
379        let _ = writeln!(out, "    const allocator = gpa.allocator();");
380
381        let _ = writeln!(out, "    var url_buf: [512]u8 = undefined;");
382        let _ = writeln!(
383            out,
384            "    const url = try std.fmt.bufPrint(&url_buf, \"{{s}}/fixtures/{fixture_id}\", .{{if (std.c.getenv(\"MOCK_SERVER_URL\")) |v| std.mem.span(v) else \"http://localhost:8080\"}});"
385        );
386
387        // Headers
388        if !ctx.headers.is_empty() {
389            let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
390            header_pairs.sort_by_key(|(k, _)| k.as_str());
391            let _ = writeln!(out, "    const headers = [_]std.http.Header{{");
392            for (k, v) in &header_pairs {
393                let ek = escape_zig(k);
394                let ev = escape_zig(v);
395                let _ = writeln!(out, "        .{{ .name = \"{ek}\", .value = \"{ev}\" }},");
396            }
397            let _ = writeln!(out, "    }};");
398        }
399
400        // Body
401        if let Some(body) = ctx.body {
402            let json_str = serde_json::to_string(body).unwrap_or_default();
403            let escaped = escape_zig(&json_str);
404            let _ = writeln!(out, "    const body_bytes: []const u8 = \"{escaped}\";");
405        }
406
407        let headers_arg = if ctx.headers.is_empty() { "&.{}" } else { "&headers" };
408        let has_body = ctx.body.is_some();
409
410        let _ = writeln!(
411            out,
412            "    var http_client = std.http.Client{{ .allocator = allocator }};"
413        );
414        let _ = writeln!(out, "    defer http_client.deinit();");
415        let _ = writeln!(out, "    var response_body = std.ArrayList(u8).init(allocator);");
416        let _ = writeln!(out, "    defer response_body.deinit();");
417
418        let method_zig = match method.as_str() {
419            "GET" => ".GET",
420            "POST" => ".POST",
421            "PUT" => ".PUT",
422            "DELETE" => ".DELETE",
423            "PATCH" => ".PATCH",
424            "HEAD" => ".HEAD",
425            "OPTIONS" => ".OPTIONS",
426            _ => ".GET",
427        };
428
429        let payload_field = if has_body { ", .payload = body_bytes" } else { "" };
430        let _ = writeln!(
431            out,
432            "    const {rv} = try http_client.fetch(.{{ .location = .{{ .url = url }}, .method = {method_zig}, .extra_headers = {headers_arg}{payload_field}, .response_storage = .{{ .dynamic = &response_body }} }});",
433            rv = ctx.response_var,
434        );
435    }
436
437    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
438        let _ = writeln!(
439            out,
440            "    try testing.expectEqual(@as(u10, {status}), @intFromEnum({response_var}.status));"
441        );
442    }
443
444    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
445        let ename = escape_zig(&name.to_lowercase());
446        match expected {
447            "<<present>>" => {
448                let _ = writeln!(
449                    out,
450                    "    // assert header '{ename}' is present (header inspection not yet implemented)"
451                );
452            }
453            "<<absent>>" => {
454                let _ = writeln!(
455                    out,
456                    "    // assert header '{ename}' is absent (header inspection not yet implemented)"
457                );
458            }
459            "<<uuid>>" => {
460                let _ = writeln!(
461                    out,
462                    "    // assert header '{ename}' matches UUID pattern (header inspection not yet implemented)"
463                );
464            }
465            exact => {
466                let evalue = escape_zig(exact);
467                let _ = writeln!(
468                    out,
469                    "    // assert header '{ename}' == \"{evalue}\" (header inspection not yet implemented)"
470                );
471            }
472        }
473    }
474
475    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
476        let json_str = serde_json::to_string(expected).unwrap_or_default();
477        let escaped = escape_zig(&json_str);
478        let _ = writeln!(
479            out,
480            "    try testing.expectEqualStrings(\"{escaped}\", response_body.items);"
481        );
482    }
483
484    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
485        if let Some(obj) = expected.as_object() {
486            for (key, val) in obj {
487                let ekey = escape_zig(key);
488                let eval = escape_zig(&serde_json::to_string(val).unwrap_or_default());
489                let _ = writeln!(
490                    out,
491                    "    // assert body contains field \"{ekey}\" = \"{eval}\" (partial JSON not yet implemented)"
492                );
493            }
494        }
495    }
496
497    fn render_assert_validation_errors(
498        &self,
499        out: &mut String,
500        _response_var: &str,
501        errors: &[crate::fixture::ValidationErrorExpectation],
502    ) {
503        for ve in errors {
504            let loc = ve.loc.join(".");
505            let escaped_loc = escape_zig(&loc);
506            let escaped_msg = escape_zig(&ve.msg);
507            let _ = writeln!(
508                out,
509                "    // assert validation error at \"{escaped_loc}\": \"{escaped_msg}\" (not yet implemented)"
510            );
511        }
512    }
513}
514
515/// Render a Zig `test "..." { ... }` block for an HTTP server fixture.
516///
517/// Delegates to the shared [`client::http_call::render_http_test`] driver via
518/// [`ZigTestClientRenderer`].
519fn render_http_test_case(out: &mut String, fixture: &Fixture) {
520    client::http_call::render_http_test(out, &ZigTestClientRenderer, fixture);
521}
522
523// ---------------------------------------------------------------------------
524// Function-call test rendering
525// ---------------------------------------------------------------------------
526
527#[allow(clippy::too_many_arguments)]
528fn render_test_file(
529    category: &str,
530    fixtures: &[&Fixture],
531    e2e_config: &E2eConfig,
532    function_name: &str,
533    result_var: &str,
534    args: &[crate::config::ArgMapping],
535    field_resolver: &FieldResolver,
536    enum_fields: &HashSet<String>,
537    module_name: &str,
538    ffi_prefix: &str,
539) -> String {
540    let mut out = String::new();
541    out.push_str(&hash::header(CommentStyle::DoubleSlash));
542    let _ = writeln!(out, "const std = @import(\"std\");");
543    let _ = writeln!(out, "const testing = std.testing;");
544    let _ = writeln!(out, "const {module_name} = @import(\"{module_name}\");");
545    let _ = writeln!(out);
546
547    let _ = writeln!(out, "// E2e tests for category: {category}");
548    let _ = writeln!(out);
549
550    for fixture in fixtures {
551        if fixture.http.is_some() {
552            render_http_test_case(&mut out, fixture);
553        } else {
554            render_test_fn(
555                &mut out,
556                fixture,
557                e2e_config,
558                function_name,
559                result_var,
560                args,
561                field_resolver,
562                enum_fields,
563                module_name,
564                ffi_prefix,
565            );
566        }
567        let _ = writeln!(out);
568    }
569
570    out
571}
572
573#[allow(clippy::too_many_arguments)]
574fn render_test_fn(
575    out: &mut String,
576    fixture: &Fixture,
577    e2e_config: &E2eConfig,
578    _function_name: &str,
579    _result_var: &str,
580    _args: &[crate::config::ArgMapping],
581    field_resolver: &FieldResolver,
582    enum_fields: &HashSet<String>,
583    module_name: &str,
584    ffi_prefix: &str,
585) {
586    // Resolve per-fixture call config.
587    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
588    let lang = "zig";
589    let call_overrides = call_config.overrides.get(lang);
590    let function_name = call_overrides
591        .and_then(|o| o.function.as_ref())
592        .cloned()
593        .unwrap_or_else(|| call_config.function.clone());
594    let result_var = &call_config.result_var;
595    let args = &call_config.args;
596    // Client factory: when set, the test instantiates a client object via
597    // `module.factory_fn(...)` and calls methods on the instance rather than
598    // calling top-level package functions directly.
599    // Mirrors the go codegen pattern (go.rs:981-1028 / CallOverride.client_factory).
600    let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
601        e2e_config
602            .call
603            .overrides
604            .get(lang)
605            .and_then(|o| o.client_factory.as_deref())
606    });
607
608    // When `result_is_json_struct = true`, the Zig function returns `[]u8` JSON.
609    // The test parses it with `std.json.parseFromSlice(std.json.Value, ...)` and
610    // traverses the dynamic JSON object for field assertions.
611    //
612    // Client-factory methods on opaque handles always return JSON `[]u8` because
613    // the zig backend serializes struct results via the FFI's `*_to_json` helper
614    // (see alef-backend-zig/src/gen_bindings/opaque_handles.rs). Force the flag
615    // on whenever a client_factory is in play so the test path parses the JSON
616    // result rather than attempting direct field access on `[]u8`.
617    //
618    // Exception: when the call returns raw bytes (e.g. speech/file_content use the
619    // FFI byte-buffer out-pointer shape and return `[]u8` audio/file bytes rather
620    // than a serialised struct). Detect this by checking the call-level flag first
621    // and then falling back to any per-language override that declares `result_is_bytes`.
622    // The zig and C bindings share the same byte-buffer convention, so a C override
623    // of `result_is_bytes = true` is a reliable proxy when no zig override exists.
624    let call_result_is_bytes = call_config.result_is_bytes || call_config.overrides.values().any(|o| o.result_is_bytes);
625    let result_is_json_struct =
626        !call_result_is_bytes && (call_overrides.is_some_and(|o| o.result_is_json_struct) || client_factory.is_some());
627
628    // Whether the bare wrapper return type is `?T` (Optional). The zig backend
629    // emits `?[]u8` for nullable JSON results and `?<Primitive>` for nullable
630    // primitives, so assertions on the bare result must use null-checks rather
631    // than `.len`.
632    let result_is_option = call_overrides.is_some_and(|o| o.result_is_option) || call_config.result_is_option;
633
634    // Whether the Zig wrapper returns an error union (`try` is required).
635    //
636    // The Zig backend nearly always returns an error union: any function with
637    // string/path/json_object/bytes parameters must allocate a null-terminated
638    // copy (→ `error{OutOfMemory}!T`), any fallible function (`returns_result`)
639    // wraps a `DomainError||error{OutOfMemory}!T`, and any function whose return
640    // type is a string/JSON/collection blob also needs heap allocation.
641    //
642    // The ONLY case where `try` is incorrect is a function that is:
643    //   - genuinely infallible (no Rust Result<T,E>)
644    //   - takes no allocating parameters (no string/path/bytes/json_object args)
645    //   - returns a primitive directly (u64, bool, etc.)
646    //
647    // Rather than attempting to infer this from incomplete config information,
648    // we default to emitting `try` and require an explicit opt-out:
649    //
650    //   [crates.e2e.calls.language_count.overrides.zig]
651    //   returns_result = false
652    //
653    // This is safer than guessing wrong and producing un-compilable Zig.
654    let call_returns_error_union = call_overrides.and_then(|o| o.returns_result) != Some(false);
655
656    let test_name = fixture.id.to_snake_case();
657    let description = &fixture.description;
658    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
659
660    let (setup_lines, args_str, setup_needs_gpa) = build_args_and_setup(&fixture.input, args, &fixture.id, module_name);
661    // Append per-call zig extra_args (e.g. `["null"]` for the trailing
662    // optional `query` parameter on `list_files` / `list_batches`). Mirrors
663    // the same mechanism used by go/python/swift codegen — zig's method
664    // signatures require every optional positional argument to be supplied
665    // explicitly, so the e2e config carries a per-language extras list.
666    let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
667    let args_str = if extra_args.is_empty() {
668        args_str
669    } else if args_str.is_empty() {
670        extra_args.join(", ")
671    } else {
672        format!("{args_str}, {}", extra_args.join(", "))
673    };
674
675    // Pre-compute whether any assertion will emit code that references `result` /
676    // `allocator`. Used to decide whether to emit the GPA allocator binding.
677    let any_happy_emits_code = fixture
678        .assertions
679        .iter()
680        .any(|a| assertion_emits_code(a, field_resolver));
681    let any_non_error_emits_code = fixture
682        .assertions
683        .iter()
684        .filter(|a| a.assertion_type != "error")
685        .any(|a| assertion_emits_code(a, field_resolver));
686
687    // Pre-compute streaming-virtual path conditions.
688    let has_streaming_virtual_assertions = fixture.assertions.iter().any(|a| {
689        a.field
690            .as_ref()
691            .is_some_and(|f| !f.is_empty() && is_streaming_virtual_field(f))
692    });
693    let is_stream_fn = function_name.contains("stream");
694    let uses_streaming_virtual_path =
695        result_is_json_struct && has_streaming_virtual_assertions && is_stream_fn && client_factory.is_some();
696    // Whether the streaming-virtual path also parses JSON (for non-streaming assertions).
697    let streaming_path_has_non_streaming = uses_streaming_virtual_path
698        && fixture.assertions.iter().any(|a| {
699            !a.field
700                .as_ref()
701                .is_some_and(|f| !f.is_empty() && is_streaming_virtual_field(f))
702                && !matches!(a.assertion_type.as_str(), "not_error" | "error")
703                && a.field
704                    .as_ref()
705                    .is_some_and(|f| !f.is_empty() && field_resolver.is_valid_for_result(f))
706        });
707
708    let _ = writeln!(out, "test \"{test_name}\" {{");
709    let _ = writeln!(out, "    // {description}");
710
711    // Visitor fixtures bypass the high-level `convert(html, options)` wrapper
712    // and inline the FFI sequence so we can attach a `HTMHtmVisitorCallbacks`
713    // vtable to the options handle. The vtable is populated by per-fixture
714    // C-callable thunks emitted by `zig_visitors::build_zig_visitor`.
715    if let Some(visitor_spec) = &fixture.visitor {
716        let html = fixture.input.get("html").and_then(|v| v.as_str()).unwrap_or_default();
717        let options_value = fixture.input.get("options").cloned();
718        emit_visitor_test_body(
719            out,
720            &fixture.id,
721            html,
722            options_value.as_ref(),
723            visitor_spec,
724            module_name,
725            &fixture.assertions,
726            expects_error,
727            field_resolver,
728        );
729        let _ = writeln!(out, "}}");
730        let _ = writeln!(out);
731        return;
732    }
733
734    // Emit GPA allocator only when it will actually be used: setup lines that
735    // need GPA allocation (mock_url), or a JSON-struct result path where the test
736    // will call `std.json.parseFromSlice`. The binding is not needed for
737    // error-only paths or tests with no field assertions.
738    // Note: `bytes` arg setup uses c_allocator directly and does NOT require GPA.
739    // For the streaming-virtual path, `allocator` is only needed if there are also
740    // non-streaming assertions that require JSON parsing via parseFromSlice.
741    let needs_gpa = setup_needs_gpa
742        || streaming_path_has_non_streaming
743        || (!uses_streaming_virtual_path && result_is_json_struct && !expects_error && any_happy_emits_code)
744        || (!uses_streaming_virtual_path && result_is_json_struct && expects_error && any_non_error_emits_code);
745    if needs_gpa {
746        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
747        let _ = writeln!(out, "    defer _ = gpa.deinit();");
748        let _ = writeln!(out, "    const allocator = gpa.allocator();");
749        let _ = writeln!(out);
750    }
751
752    for line in &setup_lines {
753        let _ = writeln!(out, "    {line}");
754    }
755
756    // Client factory: when configured, instantiate a client object via the named
757    // constructor function and call the method on the instance.
758    // The client is pointed at MOCK_SERVER_URL/fixtures/<id> (mirrors go.rs:981-1028).
759    // When not configured, fall back to calling the top-level package function directly.
760    let call_prefix = if let Some(factory) = client_factory {
761        let fixture_id = &fixture.id;
762        let _ = writeln!(
763            out,
764            "    const _mock_url = try std.fmt.allocPrintSentinel(std.heap.c_allocator, \"{{s}}/fixtures/{fixture_id}\", .{{if (std.c.getenv(\"MOCK_SERVER_URL\")) |v| std.mem.span(v) else \"http://localhost:8080\"}}, 0);"
765        );
766        let _ = writeln!(out, "    defer std.heap.c_allocator.free(_mock_url);");
767        let _ = writeln!(
768            out,
769            "    var _client = try {module_name}.{factory}(\"test-key\", _mock_url, null, null, null);"
770        );
771        let _ = writeln!(out, "    defer _client.free();");
772        "_client".to_string()
773    } else {
774        module_name.to_string()
775    };
776
777    if expects_error {
778        // Error-path test: use error union syntax `!T` and try-catch.
779        // Async functions execute via tokio::runtime::block_on in the FFI shim,
780        // so the call site is synchronous from Zig's perspective.
781        if result_is_json_struct {
782            let _ = writeln!(
783                out,
784                "    const _result_json = {call_prefix}.{function_name}({args_str}) catch {{"
785            );
786        } else {
787            let _ = writeln!(
788                out,
789                "    const result = {call_prefix}.{function_name}({args_str}) catch {{"
790            );
791        }
792        let _ = writeln!(out, "        try testing.expect(true); // Error occurred as expected");
793        let _ = writeln!(out, "        return;");
794        let _ = writeln!(out, "    }};");
795        // Whether any non-error assertion will emit code that references `result`.
796        // If not, we must explicitly discard `result` to satisfy Zig's
797        // strict-unused-locals rule.
798        let any_emits_code = fixture
799            .assertions
800            .iter()
801            .filter(|a| a.assertion_type != "error")
802            .any(|a| assertion_emits_code(a, field_resolver));
803        if result_is_json_struct && any_emits_code {
804            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
805            let _ = writeln!(
806                out,
807                "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
808            );
809            let _ = writeln!(out, "    defer _parsed.deinit();");
810            let _ = writeln!(out, "    const {result_var} = &_parsed.value;");
811            let _ = writeln!(out, "    // Perform success assertions if any");
812            for assertion in &fixture.assertions {
813                if assertion.assertion_type != "error" {
814                    render_json_assertion(out, assertion, result_var, field_resolver, false);
815                }
816            }
817        } else if result_is_json_struct {
818            let _ = writeln!(out, "    _ = _result_json;");
819        } else if any_emits_code {
820            let _ = writeln!(out, "    // Perform success assertions if any");
821            for assertion in &fixture.assertions {
822                if assertion.assertion_type != "error" {
823                    render_assertion(
824                        out,
825                        assertion,
826                        result_var,
827                        field_resolver,
828                        enum_fields,
829                        result_is_option,
830                    );
831                }
832            }
833        } else {
834            let _ = writeln!(out, "    _ = result;");
835        }
836    } else if fixture.assertions.is_empty() {
837        // No assertions: emit a call to verify compilation.
838        if result_is_json_struct {
839            let _ = writeln!(
840                out,
841                "    const _result_json = try {call_prefix}.{function_name}({args_str});"
842            );
843            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
844        } else if call_returns_error_union {
845            let _ = writeln!(out, "    _ = try {call_prefix}.{function_name}({args_str});");
846        } else {
847            let _ = writeln!(out, "    _ = {call_prefix}.{function_name}({args_str});");
848        }
849    } else {
850        // Happy path: call and assert. Detect whether any assertion actually
851        // emits code that references `result` (some — like `not_error` — emit
852        // nothing) so we don't leave an unused local, which Zig 0.16 rejects.
853        let any_emits_code = fixture
854            .assertions
855            .iter()
856            .any(|a| assertion_emits_code(a, field_resolver));
857        if call_result_is_bytes && client_factory.is_some() {
858            // Bytes path: the function returns raw `[]u8` (audio/file bytes), not
859            // a JSON struct. Call, defer-free, then check len for not_empty/is_empty.
860            let _ = writeln!(
861                out,
862                "    const _result_json = try {call_prefix}.{function_name}({args_str});"
863            );
864            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
865            let has_bytes_assertions = fixture
866                .assertions
867                .iter()
868                .any(|a| matches!(a.assertion_type.as_str(), "not_empty" | "is_empty"));
869            if has_bytes_assertions {
870                for assertion in &fixture.assertions {
871                    match assertion.assertion_type.as_str() {
872                        "not_empty" => {
873                            let _ = writeln!(out, "    try testing.expect(_result_json.len > 0);");
874                        }
875                        "is_empty" => {
876                            let _ = writeln!(out, "    try testing.expectEqual(@as(usize, 0), _result_json.len);");
877                        }
878                        "not_error" | "error" => {}
879                        _ => {
880                            let atype = &assertion.assertion_type;
881                            let _ = writeln!(
882                                out,
883                                "    // bytes result: assertion '{atype}' not implemented for zig bytes"
884                            );
885                        }
886                    }
887                }
888            }
889        } else if result_is_json_struct {
890            // When streaming-virtual field assertions are present (pre-computed above),
891            // emit raw FFI code to collect all chunks instead of calling
892            // `chat_stream` (which only returns the last chunk's JSON).
893            if uses_streaming_virtual_path {
894                let request_from_json = format!("{ffi_prefix}_chat_completion_request_from_json");
895                let request_free = format!("{ffi_prefix}_chat_completion_request_free");
896                let stream_start = format!("{ffi_prefix}_default_client_chat_stream_start");
897                let stream_free = format!("{ffi_prefix}_default_client_chat_stream_free");
898                let client_c_type = format!("{}DefaultClient", ffi_prefix.to_shouty_snake_case());
899
900                // Streaming-virtual path: inline FFI collect.
901                // Build a sentinel-terminated request string.
902                let _ = writeln!(
903                    out,
904                    "    const _req_z = try std.heap.c_allocator.dupeZ(u8, {args_str});"
905                );
906                let _ = writeln!(out, "    defer std.heap.c_allocator.free(_req_z);");
907                let _ = writeln!(
908                    out,
909                    "    const _req_handle = {module_name}.c.{request_from_json}(_req_z.ptr);"
910                );
911                let _ = writeln!(out, "    defer {module_name}.c.{request_free}(_req_handle);");
912                let _ = writeln!(
913                    out,
914                    "    const _stream_handle = {module_name}.c.{stream_start}(@as(*{module_name}.c.{client_c_type}, @ptrCast(_client._handle)), _req_handle);"
915                );
916                let _ = writeln!(out, "    if (_stream_handle == null) return error.StreamStartFailed;");
917                let _ = writeln!(out, "    defer {module_name}.c.{stream_free}(_stream_handle);");
918                // Emit the collect snippet (already has 4-space indentation baked in).
919                let snip =
920                    StreamingFieldResolver::collect_snippet_zig("_stream_handle", "chunks", module_name, ffi_prefix);
921                out.push_str("    ");
922                out.push_str(&snip);
923                out.push('\n');
924                // For non-streaming assertions (e.g. usage), we also need _result_json.
925                // Re-serialize the last chunk in `chunks` to get the JSON.
926                if streaming_path_has_non_streaming {
927                    let _ = writeln!(
928                        out,
929                        "    const _result_json = if (chunks.items.len > 0) chunks.items[chunks.items.len - 1] else &[_]u8{{}};"
930                    );
931                    let _ = writeln!(
932                        out,
933                        "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
934                    );
935                    let _ = writeln!(out, "    defer _parsed.deinit();");
936                    let _ = writeln!(out, "    const {result_var} = &_parsed.value;");
937                }
938                for assertion in &fixture.assertions {
939                    render_json_assertion(out, assertion, result_var, field_resolver, true);
940                }
941            } else {
942                // JSON struct path: parse result JSON and access fields dynamically.
943                let _ = writeln!(
944                    out,
945                    "    const _result_json = try {call_prefix}.{function_name}({args_str});"
946                );
947                let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
948                if any_emits_code {
949                    let _ = writeln!(
950                        out,
951                        "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
952                    );
953                    let _ = writeln!(out, "    defer _parsed.deinit();");
954                    let _ = writeln!(out, "    const {result_var} = &_parsed.value;");
955                    for assertion in &fixture.assertions {
956                        render_json_assertion(out, assertion, result_var, field_resolver, false);
957                    }
958                }
959            }
960        } else if any_emits_code {
961            let try_kw = if call_returns_error_union { "try " } else { "" };
962            let _ = writeln!(
963                out,
964                "    const {result_var} = {try_kw}{call_prefix}.{function_name}({args_str});"
965            );
966            for assertion in &fixture.assertions {
967                render_assertion(
968                    out,
969                    assertion,
970                    result_var,
971                    field_resolver,
972                    enum_fields,
973                    result_is_option,
974                );
975            }
976        } else if call_returns_error_union {
977            let _ = writeln!(out, "    _ = try {call_prefix}.{function_name}({args_str});");
978        } else {
979            let _ = writeln!(out, "    _ = {call_prefix}.{function_name}({args_str});");
980        }
981    }
982
983    let _ = writeln!(out, "}}");
984}
985
986/// Emit the body of a visitor-bearing test. Drives the FFI directly so we
987/// can attach a `HTMHtmVisitorCallbacks` vtable to the `ConversionOptions`
988/// handle before calling `htm_convert`. The high-level `convert(html,
989/// options)` wrapper cannot carry a visitor because the visitor is a Rust
990/// trait object, not a JSON-encodable field.
991#[allow(clippy::too_many_arguments)]
992fn emit_visitor_test_body(
993    out: &mut String,
994    fixture_id: &str,
995    html: &str,
996    options_value: Option<&serde_json::Value>,
997    visitor_spec: &crate::fixture::VisitorSpec,
998    module_name: &str,
999    assertions: &[Assertion],
1000    expects_error: bool,
1001    field_resolver: &FieldResolver,
1002) {
1003    // Allocator for the JSON-parse of the result blob (and any helper allocs).
1004    let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
1005    let _ = writeln!(out, "    defer _ = gpa.deinit();");
1006    let _ = writeln!(out, "    const allocator = gpa.allocator();");
1007    let _ = writeln!(out);
1008
1009    // 1. Per-fixture visitor struct + callbacks table.
1010    let visitor_block = super::zig_visitors::build_zig_visitor(fixture_id, module_name, visitor_spec);
1011    out.push_str(&visitor_block);
1012
1013    // 2. Materialise the visitor handle (HtmVisitor opaque, attached via
1014    //    htm_options_set_visitor_handle).
1015    let _ = writeln!(
1016        out,
1017        "    const _visitor = {module_name}.c.htm_visitor_create(&_callbacks);"
1018    );
1019    let _ = writeln!(out, "    defer {module_name}.c.htm_visitor_free(_visitor);");
1020
1021    // 3. Options handle: always allocate one (even when the fixture supplies
1022    //    no `options`) so we have somewhere to attach the visitor. The FFI
1023    //    accepts `"{}"` as an empty options JSON.
1024    let options_json = match options_value {
1025        Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1026        None => "{}".to_string(),
1027    };
1028    let escaped_options = escape_zig(&options_json);
1029    let _ = writeln!(
1030        out,
1031        "    const _options_z = try std.heap.c_allocator.dupeZ(u8, \"{escaped_options}\");"
1032    );
1033    let _ = writeln!(out, "    defer std.heap.c_allocator.free(_options_z);");
1034    let _ = writeln!(
1035        out,
1036        "    const _options = {module_name}.c.htm_conversion_options_from_json(_options_z.ptr);"
1037    );
1038    let _ = writeln!(out, "    defer {module_name}.c.htm_conversion_options_free(_options);");
1039    let _ = writeln!(
1040        out,
1041        "    {module_name}.c.htm_options_set_visitor_handle(_options, _visitor);"
1042    );
1043
1044    // 4. HTML buffer + convert call.
1045    let escaped_html = escape_zig(html);
1046    let _ = writeln!(
1047        out,
1048        "    const _html_z = try std.heap.c_allocator.dupeZ(u8, \"{escaped_html}\");"
1049    );
1050    let _ = writeln!(out, "    defer std.heap.c_allocator.free(_html_z);");
1051    let _ = writeln!(
1052        out,
1053        "    const _result = {module_name}.c.htm_convert(_html_z.ptr, _options);"
1054    );
1055
1056    if expects_error {
1057        // Error-path: _result null OR last error code non-zero.
1058        let _ = writeln!(
1059            out,
1060            "    try testing.expect(_result == null or {module_name}.c.htm_last_error_code() != 0);"
1061        );
1062        let _ = writeln!(
1063            out,
1064            "    if (_result) |r| {module_name}.c.htm_conversion_result_free(r);"
1065        );
1066        return;
1067    }
1068
1069    let _ = writeln!(out, "    try testing.expect(_result != null);");
1070    let _ = writeln!(out, "    defer {module_name}.c.htm_conversion_result_free(_result.?);");
1071    let _ = writeln!(
1072        out,
1073        "    const _json_ptr = {module_name}.c.htm_conversion_result_to_json(_result.?);"
1074    );
1075    let _ = writeln!(out, "    defer {module_name}.c.htm_free_string(_json_ptr);");
1076    let _ = writeln!(out, "    const _result_json = std.mem.sliceTo(_json_ptr, 0);");
1077    let _ = writeln!(
1078        out,
1079        "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
1080    );
1081    let _ = writeln!(out, "    defer _parsed.deinit();");
1082    let _ = writeln!(out, "    const result = &_parsed.value;");
1083
1084    for assertion in assertions {
1085        if assertion.assertion_type != "error" {
1086            render_json_assertion(out, assertion, "result", field_resolver, false);
1087        }
1088    }
1089}
1090
1091// ---------------------------------------------------------------------------
1092// JSON-struct assertion rendering (for result_is_json_struct = true)
1093// ---------------------------------------------------------------------------
1094
1095/// Convert a dot-separated field path into a chain of `std.json.Value` lookups.
1096///
1097/// Each segment uses `.object.get("key").?` to traverse the JSON object tree.
1098/// The final segment stops before the leaf-type accessor so callers can append
1099/// the appropriate accessor (`.string`, `.integer`, `.array.items`, etc.).
1100///
1101/// Returns `(base_expr, last_key)` where `base_expr` already includes all
1102/// intermediate `.object.get("…").?` dereferences up to (but not including)
1103/// the leaf, and `last_key` is the last path segment.
1104/// Variant names of `FormatMetadata` (snake_case, from `#[serde(rename_all = "snake_case")]`).
1105/// These appear as typed accessors in fixture paths (e.g. `format.excel.sheet_count`)
1106/// but are NOT JSON keys — `FormatMetadata` is internally tagged so variant fields are
1107/// flattened directly into the `format` object alongside the `format_type` discriminant.
1108const FORMAT_METADATA_VARIANTS: &[&str] = &[
1109    "pdf",
1110    "docx",
1111    "excel",
1112    "email",
1113    "pptx",
1114    "archive",
1115    "image",
1116    "xml",
1117    "text",
1118    "html",
1119    "ocr",
1120    "csv",
1121    "bibtex",
1122    "citation",
1123    "fiction_book",
1124    "dbf",
1125    "jats",
1126    "epub",
1127    "pst",
1128    "code",
1129];
1130
1131fn json_path_expr(result_var: &str, field_path: &str) -> String {
1132    let segments: Vec<&str> = field_path.split('.').collect();
1133    let mut expr = result_var.to_string();
1134    let mut prev_seg: Option<&str> = None;
1135    for seg in &segments {
1136        // Skip variant-name accessor segments that follow a `format` key.
1137        // FormatMetadata is an internally-tagged enum (`#[serde(tag = "format_type")]`),
1138        // so variant fields are flattened directly into the format object — there is no
1139        // intermediate JSON key for the variant name.
1140        if prev_seg == Some("format") && FORMAT_METADATA_VARIANTS.contains(seg) {
1141            prev_seg = Some(seg);
1142            continue;
1143        }
1144        // Handle array accessor notation:
1145        //   "links[]"     → access the array, then first element.
1146        //   "results[0]"  → access the array, then specific index N.
1147        if let Some(key) = seg.strip_suffix("[]") {
1148            expr = format!("{expr}.object.get(\"{key}\").?.array.items[0]");
1149        } else if let Some(bracket_pos) = seg.find('[') {
1150            if let Some(end_pos) = seg.find(']') {
1151                if end_pos > bracket_pos + 1 && end_pos == seg.len() - 1 {
1152                    let key = &seg[..bracket_pos];
1153                    let idx = &seg[bracket_pos + 1..end_pos];
1154                    if idx.chars().all(|c| c.is_ascii_digit()) {
1155                        expr = format!("{expr}.object.get(\"{key}\").?.array.items[{idx}]");
1156                        prev_seg = Some(seg);
1157                        continue;
1158                    }
1159                    // Non-numeric bracket: HashMap<String, _> key access. FRB / serde
1160                    // serialize maps as JSON objects, so `field[key]` resolves to
1161                    // `.object.get("field").?.object.get("key").?`. Used by h2m's
1162                    // `metadata.document.open_graph[title]` alias pattern where
1163                    // `open_graph` is a `HashMap<String, String>`.
1164                    expr = format!("{expr}.object.get(\"{key}\").?.object.get(\"{idx}\").?");
1165                    prev_seg = Some(seg);
1166                    continue;
1167                }
1168            }
1169            expr = format!("{expr}.object.get(\"{seg}\").?");
1170        } else {
1171            expr = format!("{expr}.object.get(\"{seg}\").?");
1172        }
1173        prev_seg = Some(seg);
1174    }
1175    expr
1176}
1177
1178/// Render a single assertion for a JSON-struct result (result_is_json_struct = true).
1179///
1180/// The `result_var` variable is `*std.json.Value` (pointer to the parsed root object).
1181/// Field paths are traversed via `.object.get("key").?` chains.
1182fn render_json_assertion(
1183    out: &mut String,
1184    assertion: &Assertion,
1185    result_var: &str,
1186    field_resolver: &FieldResolver,
1187    uses_streaming: bool,
1188) {
1189    // Intercept streaming-virtual fields before the result-type validity check,
1190    // but ONLY when the test is actually using the streaming-virtual path.
1191    // When `uses_streaming = false` the `chunks` local is never declared, so
1192    // generating `chunks.items.len` would produce a compile error. Fields like
1193    // "chunks" that happen to share a streaming-virtual name are regular JSON
1194    // fields in non-streaming results and must fall through to the JSON path.
1195    if let Some(f) = &assertion.field {
1196        if uses_streaming && !f.is_empty() && is_streaming_virtual_field(f) {
1197            if let Some(expr) = StreamingFieldResolver::accessor(f, "zig", "chunks") {
1198                match assertion.assertion_type.as_str() {
1199                    "count_min" => {
1200                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1201                            let _ = writeln!(out, "    try testing.expect({expr}.len >= {n});");
1202                        }
1203                    }
1204                    "count_equals" => {
1205                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1206                            let _ = writeln!(out, "    try testing.expectEqual(@as(usize, {n}), {expr}.len);");
1207                        }
1208                    }
1209                    "equals" => {
1210                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1211                            let escaped = escape_zig(s);
1212                            let _ = writeln!(out, "    try testing.expectEqualStrings(\"{escaped}\", {expr});");
1213                        } else if let Some(v) = &assertion.value {
1214                            let zig_val = json_to_zig(v);
1215                            let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {expr});");
1216                        }
1217                    }
1218                    "not_empty" => {
1219                        let _ = writeln!(out, "    try testing.expect({expr}.len > 0);");
1220                    }
1221                    "is_true" => {
1222                        let _ = writeln!(out, "    try testing.expect({expr});");
1223                    }
1224                    "is_false" => {
1225                        let _ = writeln!(out, "    try testing.expect(!{expr});");
1226                    }
1227                    _ => {
1228                        let atype = &assertion.assertion_type;
1229                        let _ = writeln!(
1230                            out,
1231                            "    // streaming virtual field '{f}' assertion '{atype}' not implemented for zig"
1232                        );
1233                    }
1234                }
1235            }
1236            return;
1237        }
1238    }
1239
1240    // Synthetic `embeddings` field on a JSON-array result (e.g. embed_texts
1241    // returns `Vec<Vec<f32>>` → JSON `[[...],[...]]`). The field name is a
1242    // convention from the fixture schema — the JSON value IS the embeddings
1243    // array. Apply the assertion against `result.array.items` directly. The
1244    // synthetic path is only used when no explicit result_fields configure
1245    // `embeddings` as a real struct field.
1246    if let Some(f) = &assertion.field {
1247        if f == "embeddings" && !field_resolver.has_explicit_field("embeddings") {
1248            match assertion.assertion_type.as_str() {
1249                "count_min" => {
1250                    if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1251                        let _ = writeln!(out, "    try testing.expect({result_var}.array.items.len >= {n});");
1252                    }
1253                    return;
1254                }
1255                "count_equals" => {
1256                    if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1257                        let _ = writeln!(
1258                            out,
1259                            "    try testing.expectEqual(@as(usize, {n}), {result_var}.array.items.len);"
1260                        );
1261                    }
1262                    return;
1263                }
1264                "not_empty" => {
1265                    let _ = writeln!(out, "    try testing.expect({result_var}.array.items.len > 0);");
1266                    return;
1267                }
1268                "is_empty" => {
1269                    let _ = writeln!(
1270                        out,
1271                        "    try testing.expectEqual(@as(usize, 0), {result_var}.array.items.len);"
1272                    );
1273                    return;
1274                }
1275                _ => {}
1276            }
1277        }
1278    }
1279
1280    // Skip assertions on fields that don't exist on the result type.
1281    if let Some(f) = &assertion.field {
1282        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1283            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
1284            return;
1285        }
1286    }
1287    // error/not_error are handled at the call level, not assertion level.
1288    if matches!(assertion.assertion_type.as_str(), "not_error" | "error") {
1289        return;
1290    }
1291
1292    let raw_field_path = assertion.field.as_deref().unwrap_or("").trim();
1293    let field_path = if raw_field_path.is_empty() {
1294        raw_field_path.to_string()
1295    } else {
1296        field_resolver.resolve(raw_field_path).to_string()
1297    };
1298    let field_path = field_path.trim();
1299
1300    // "{array_field}.length" → strip suffix; use .array.items.len in the template.
1301    let (field_path_for_expr, is_length_access) = if let Some(parent) = field_path.strip_suffix(".length") {
1302        (parent, true)
1303    } else {
1304        (field_path, false)
1305    };
1306
1307    let field_expr = if field_path_for_expr.is_empty() {
1308        result_var.to_string()
1309    } else {
1310        json_path_expr(result_var, field_path_for_expr)
1311    };
1312
1313    // Compute context variables for the template.
1314    let zig_val = match &assertion.value {
1315        Some(serde_json::Value::String(s)) => format!("\"{}\"", escape_zig(s)),
1316        _ => String::new(),
1317    };
1318    let is_string_val = matches!(&assertion.value, Some(serde_json::Value::String(_)));
1319    let is_bool_val = matches!(&assertion.value, Some(serde_json::Value::Bool(_)));
1320    let bool_val = match &assertion.value {
1321        Some(serde_json::Value::Bool(b)) if *b => "true",
1322        _ => "false",
1323    };
1324    let is_null_val = matches!(&assertion.value, Some(serde_json::Value::Null));
1325    let n = assertion.value.as_ref().map(json_to_zig).unwrap_or_default();
1326    let has_n = assertion.value.as_ref().is_some_and(|v| v.is_number() || v.is_u64());
1327    // Distinguish float vs integer JSON values: `std.json.Value` exposes
1328    // `.integer` (i64) and `.float` (f64) as separate variants. Comparing
1329    // `.integer` against a literal with a fractional part (e.g. `0.9`) is a
1330    // Zig compile error, so the template must select the right tag.
1331    let is_float_val = matches!(&assertion.value, Some(serde_json::Value::Number(n)) if !n.is_i64() && !n.is_u64());
1332    let values_list: Vec<String> = assertion
1333        .values
1334        .as_deref()
1335        .unwrap_or_default()
1336        .iter()
1337        .filter_map(|v| {
1338            if let serde_json::Value::String(s) = v {
1339                Some(format!("\"{}\"", escape_zig(s)))
1340            } else {
1341                None
1342            }
1343        })
1344        .collect();
1345
1346    let rendered = crate::template_env::render(
1347        "zig/json_assertion.jinja",
1348        minijinja::context! {
1349            assertion_type => assertion.assertion_type.as_str(),
1350            field_expr => field_expr,
1351            is_length_access => is_length_access,
1352            zig_val => zig_val,
1353            is_string_val => is_string_val,
1354            is_bool_val => is_bool_val,
1355            bool_val => bool_val,
1356            is_null_val => is_null_val,
1357            n => n,
1358            has_n => has_n,
1359            is_float_val => is_float_val,
1360            values_list => values_list,
1361        },
1362    );
1363    out.push_str(&rendered);
1364}
1365
1366/// Predicate matching `render_assertion`: returns true when the assertion
1367/// would emit at least one statement that references the result variable.
1368fn assertion_emits_code(assertion: &Assertion, field_resolver: &FieldResolver) -> bool {
1369    if let Some(f) = &assertion.field {
1370        if !f.is_empty() && is_streaming_virtual_field(f) {
1371            // Streaming virtual fields always emit code — they are handled in a
1372            // dedicated collect path, not skipped.
1373        } else if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1374            return false;
1375        }
1376    }
1377    matches!(
1378        assertion.assertion_type.as_str(),
1379        "equals"
1380            | "contains"
1381            | "contains_all"
1382            | "not_contains"
1383            | "not_empty"
1384            | "is_empty"
1385            | "starts_with"
1386            | "ends_with"
1387            | "min_length"
1388            | "max_length"
1389            | "count_min"
1390            | "count_equals"
1391            | "is_true"
1392            | "is_false"
1393            | "greater_than"
1394            | "less_than"
1395            | "greater_than_or_equal"
1396            | "less_than_or_equal"
1397            | "contains_any"
1398    )
1399}
1400
1401/// Build setup lines and the argument list for the function call.
1402///
1403/// Returns `(setup_lines, args_str, setup_needs_gpa)` where `setup_needs_gpa`
1404/// is `true` when at least one setup line requires the GPA `allocator` binding.
1405fn build_args_and_setup(
1406    input: &serde_json::Value,
1407    args: &[crate::config::ArgMapping],
1408    fixture_id: &str,
1409    _module_name: &str,
1410) -> (Vec<String>, String, bool) {
1411    if args.is_empty() {
1412        return (Vec::new(), String::new(), false);
1413    }
1414
1415    let mut setup_lines: Vec<String> = Vec::new();
1416    let mut parts: Vec<String> = Vec::new();
1417    let mut setup_needs_gpa = false;
1418
1419    for arg in args {
1420        if arg.arg_type == "mock_url" {
1421            let name = arg.name.clone();
1422            let id_upper = fixture_id.to_uppercase();
1423            setup_lines.push(format!(
1424                "const {name} = if (std.c.getenv(\"MOCK_SERVER_{id_upper}\")) |_pf| try std.fmt.allocPrint(allocator, \"{{s}}\", .{{std.mem.span(_pf)}}) else try std.fmt.allocPrint(allocator, \"{{s}}/fixtures/{fixture_id}\", .{{if (std.c.getenv(\"MOCK_SERVER_URL\")) |v| std.mem.span(v) else \"http://localhost:8080\"}});"
1425            ));
1426            setup_lines.push(format!("defer allocator.free({name});"));
1427            parts.push(name);
1428            setup_needs_gpa = true;
1429            continue;
1430        }
1431
1432        // Handle args (engine handle): serialize config to JSON string literal, or null.
1433        // The Zig binding accepts ?[]const u8 for engine params (creates handle internally).
1434        if arg.arg_type == "handle" {
1435            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1436            let json_str = match input.get(field) {
1437                Some(serde_json::Value::Null) | None => "null".to_string(),
1438                Some(v) => format!("\"{}\"", escape_zig(&serde_json::to_string(v).unwrap_or_default())),
1439            };
1440            parts.push(json_str);
1441            continue;
1442        }
1443
1444        // The Zig wrapper accepts struct parameters (e.g. `ExtractionConfig`)
1445        // as JSON `[]const u8`, converting them to opaque FFI handles via the
1446        // `<prefix>_<snake>_from_json` helper at the binding layer. Emit the
1447        // fixture's configuration value as a JSON string literal, falling back
1448        // to `"{}"` when the fixture omits a config so callers exercise the
1449        // default path.
1450        if arg.name == "config" && arg.arg_type == "json_object" {
1451            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1452            let json_str = match input.get(field) {
1453                Some(serde_json::Value::Null) | None => "{}".to_string(),
1454                Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1455            };
1456            parts.push(format!("\"{}\"", escape_zig(&json_str)));
1457            continue;
1458        }
1459
1460        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1461        // When `field` is empty or refers to `input` itself (no dotted subfield),
1462        // the entire fixture `input` value is the payload — most commonly for
1463        // `json_object` request bodies (chat/embed/etc.). Without this guard
1464        // `input.get("input")` returns `None` and we fall through to `"{}"`,
1465        // which the FFI rejects as a deserialization error.
1466        let val = if field.is_empty() || field == "input" {
1467            Some(input)
1468        } else {
1469            input.get(field)
1470        };
1471        match val {
1472            None | Some(serde_json::Value::Null) if arg.optional => {
1473                // Zig functions don't have default arguments, so we must
1474                // pass `null` explicitly for every optional parameter.
1475                parts.push("null".to_string());
1476            }
1477            None | Some(serde_json::Value::Null) => {
1478                let default_val = match arg.arg_type.as_str() {
1479                    "string" => "\"\"".to_string(),
1480                    "int" | "integer" => "0".to_string(),
1481                    "float" | "number" => "0.0".to_string(),
1482                    "bool" | "boolean" => "false".to_string(),
1483                    "json_object" => "\"{}\"".to_string(),
1484                    _ => "null".to_string(),
1485                };
1486                parts.push(default_val);
1487            }
1488            Some(v) => {
1489                // For `json_object` arguments other than `config` (handled
1490                // above) the Zig binding accepts a JSON `[]const u8`, so we
1491                // serialize the entire fixture value as a single JSON string
1492                // literal rather than rendering it as a Zig array/struct.
1493                if arg.arg_type == "json_object" {
1494                    let json_str = serde_json::to_string(v).unwrap_or_default();
1495                    parts.push(format!("\"{}\"", escape_zig(&json_str)));
1496                } else if arg.arg_type == "bytes" {
1497                    // `bytes` args are file paths in fixtures — read the file into a
1498                    // local buffer. The cwd is set to test_documents/ at runtime.
1499                    // Zig 0.16 uses std.Io.Dir.cwd() (not std.fs.cwd()) and requires
1500                    // an `io` instance from std.testing.io in test context.
1501                    if let serde_json::Value::String(path) = v {
1502                        let var_name = format!("{}_bytes", arg.name);
1503                        let epath = escape_zig(path);
1504                        setup_lines.push(format!(
1505                            "const {var_name} = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, \"{epath}\", std.heap.c_allocator, .unlimited);"
1506                        ));
1507                        setup_lines.push(format!("defer std.heap.c_allocator.free({var_name});"));
1508                        parts.push(var_name);
1509                    } else {
1510                        parts.push(json_to_zig(v));
1511                    }
1512                } else {
1513                    parts.push(json_to_zig(v));
1514                }
1515            }
1516        }
1517    }
1518
1519    (setup_lines, parts.join(", "), setup_needs_gpa)
1520}
1521
1522fn render_assertion(
1523    out: &mut String,
1524    assertion: &Assertion,
1525    result_var: &str,
1526    field_resolver: &FieldResolver,
1527    enum_fields: &HashSet<String>,
1528    result_is_option: bool,
1529) {
1530    // Bare-result assertions on `?T` (Optional) translate to null-checks instead
1531    // of `.len`. Mirrors the same behaviour in kotlin.rs (bare_result_is_option).
1532    let bare_result_is_option = result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1533    if bare_result_is_option {
1534        match assertion.assertion_type.as_str() {
1535            "is_empty" => {
1536                let _ = writeln!(out, "    try testing.expect({result_var} == null);");
1537                return;
1538            }
1539            "not_empty" | "not_error" => {
1540                let _ = writeln!(out, "    try testing.expect({result_var} != null);");
1541                return;
1542            }
1543            _ => {}
1544        }
1545    }
1546    // Synthetic-field 'embeddings' on a JSON-bytes result (e.g. embed_texts
1547    // returns `Vec<Vec<f32>>` serialised as JSON). Parse the JSON array and
1548    // apply count_min/count_equals/not_empty/is_empty against the element count.
1549    if let Some(f) = &assertion.field {
1550        if f == "embeddings" && !field_resolver.is_valid_for_result(f) {
1551            match assertion.assertion_type.as_str() {
1552                "count_min" | "count_equals" | "not_empty" | "is_empty" => {
1553                    let _ = writeln!(out, "    {{");
1554                    let _ = writeln!(
1555                        out,
1556                        "        var _eparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {result_var}, .{{}});"
1557                    );
1558                    let _ = writeln!(out, "        defer _eparse.deinit();");
1559                    let _ = writeln!(out, "        const _embeddings_len = _eparse.value.array.items.len;");
1560                    match assertion.assertion_type.as_str() {
1561                        "count_min" => {
1562                            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1563                                let _ = writeln!(out, "        try testing.expect(_embeddings_len >= {n});");
1564                            }
1565                        }
1566                        "count_equals" => {
1567                            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1568                                let _ = writeln!(
1569                                    out,
1570                                    "        try testing.expectEqual(@as(usize, {n}), _embeddings_len);"
1571                                );
1572                            }
1573                        }
1574                        "not_empty" => {
1575                            let _ = writeln!(out, "        try testing.expect(_embeddings_len > 0);");
1576                        }
1577                        "is_empty" => {
1578                            let _ = writeln!(out, "        try testing.expectEqual(@as(usize, 0), _embeddings_len);");
1579                        }
1580                        _ => {}
1581                    }
1582                    let _ = writeln!(out, "    }}");
1583                    return;
1584                }
1585                _ => {}
1586            }
1587        }
1588    }
1589
1590    // Skip assertions on fields that don't exist on the result type.
1591    if let Some(f) = &assertion.field {
1592        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1593            let _ = writeln!(out, "    // skipped: field '{{f}}' not available on result type");
1594            return;
1595        }
1596    }
1597
1598    // Determine if this field is an enum type.
1599    let _field_is_enum = assertion
1600        .field
1601        .as_deref()
1602        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1603
1604    let field_expr = match &assertion.field {
1605        Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
1606        _ => result_var.to_string(),
1607    };
1608
1609    match assertion.assertion_type.as_str() {
1610        "equals" => {
1611            if let Some(expected) = &assertion.value {
1612                let zig_val = json_to_zig(expected);
1613                let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {field_expr});");
1614            }
1615        }
1616        "contains" => {
1617            if let Some(expected) = &assertion.value {
1618                let zig_val = json_to_zig(expected);
1619                let _ = writeln!(
1620                    out,
1621                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1622                );
1623            }
1624        }
1625        "contains_all" => {
1626            if let Some(values) = &assertion.values {
1627                for val in values {
1628                    let zig_val = json_to_zig(val);
1629                    let _ = writeln!(
1630                        out,
1631                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1632                    );
1633                }
1634            }
1635        }
1636        "not_contains" => {
1637            if let Some(expected) = &assertion.value {
1638                let zig_val = json_to_zig(expected);
1639                let _ = writeln!(
1640                    out,
1641                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1642                );
1643            } else if let Some(values) = &assertion.values {
1644                // not_contains with a plural `values` list: assert none of the entries
1645                // appear in the field. Emit one expect line per needle so failures
1646                // pinpoint the offending value.
1647                for val in values {
1648                    let zig_val = json_to_zig(val);
1649                    let _ = writeln!(
1650                        out,
1651                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1652                    );
1653                }
1654            }
1655        }
1656        "not_empty" => {
1657            let _ = writeln!(out, "    try testing.expect({field_expr}.len > 0);");
1658        }
1659        "is_empty" => {
1660            let _ = writeln!(out, "    try testing.expect({field_expr}.len == 0);");
1661        }
1662        "starts_with" => {
1663            if let Some(expected) = &assertion.value {
1664                let zig_val = json_to_zig(expected);
1665                let _ = writeln!(
1666                    out,
1667                    "    try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
1668                );
1669            }
1670        }
1671        "ends_with" => {
1672            if let Some(expected) = &assertion.value {
1673                let zig_val = json_to_zig(expected);
1674                let _ = writeln!(
1675                    out,
1676                    "    try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
1677                );
1678            }
1679        }
1680        "min_length" => {
1681            if let Some(val) = &assertion.value {
1682                if let Some(n) = val.as_u64() {
1683                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
1684                }
1685            }
1686        }
1687        "max_length" => {
1688            if let Some(val) = &assertion.value {
1689                if let Some(n) = val.as_u64() {
1690                    let _ = writeln!(out, "    try testing.expect({field_expr}.len <= {n});");
1691                }
1692            }
1693        }
1694        "count_min" => {
1695            if let Some(val) = &assertion.value {
1696                if let Some(n) = val.as_u64() {
1697                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
1698                }
1699            }
1700        }
1701        "count_equals" => {
1702            if let Some(val) = &assertion.value {
1703                if let Some(n) = val.as_u64() {
1704                    // When there is no field (field_expr == result_var), the result
1705                    // is `[]u8` JSON (e.g. batch functions). Parse the JSON array
1706                    // and count its elements; `.len` would give byte count, not item count.
1707                    let has_field = assertion.field.as_deref().is_some_and(|f| !f.is_empty());
1708                    if has_field {
1709                        let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.len);");
1710                    } else {
1711                        let _ = writeln!(out, "    {{");
1712                        let _ = writeln!(
1713                            out,
1714                            "        var _cparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {field_expr}, .{{}});"
1715                        );
1716                        let _ = writeln!(out, "        defer _cparse.deinit();");
1717                        let _ = writeln!(
1718                            out,
1719                            "        try testing.expectEqual({n}, _cparse.value.array.items.len);"
1720                        );
1721                        let _ = writeln!(out, "    }}");
1722                    }
1723                }
1724            }
1725        }
1726        "is_true" => {
1727            let _ = writeln!(out, "    try testing.expect({field_expr});");
1728        }
1729        "is_false" => {
1730            let _ = writeln!(out, "    try testing.expect(!{field_expr});");
1731        }
1732        "not_error" => {
1733            // Already handled by the call succeeding.
1734        }
1735        "error" => {
1736            // Handled at the test function level.
1737        }
1738        "greater_than" => {
1739            if let Some(val) = &assertion.value {
1740                let zig_val = json_to_zig(val);
1741                let _ = writeln!(out, "    try testing.expect({field_expr} > {zig_val});");
1742            }
1743        }
1744        "less_than" => {
1745            if let Some(val) = &assertion.value {
1746                let zig_val = json_to_zig(val);
1747                let _ = writeln!(out, "    try testing.expect({field_expr} < {zig_val});");
1748            }
1749        }
1750        "greater_than_or_equal" => {
1751            if let Some(val) = &assertion.value {
1752                let zig_val = json_to_zig(val);
1753                let _ = writeln!(out, "    try testing.expect({field_expr} >= {zig_val});");
1754            }
1755        }
1756        "less_than_or_equal" => {
1757            if let Some(val) = &assertion.value {
1758                let zig_val = json_to_zig(val);
1759                let _ = writeln!(out, "    try testing.expect({field_expr} <= {zig_val});");
1760            }
1761        }
1762        "contains_any" => {
1763            // At least ONE of the values must be found in the field (OR logic).
1764            if let Some(values) = &assertion.values {
1765                let string_values: Vec<String> = values
1766                    .iter()
1767                    .filter_map(|v| {
1768                        if let serde_json::Value::String(s) = v {
1769                            Some(format!(
1770                                "std.mem.indexOf(u8, {field_expr}, \"{}\") != null",
1771                                escape_zig(s)
1772                            ))
1773                        } else {
1774                            None
1775                        }
1776                    })
1777                    .collect();
1778                if !string_values.is_empty() {
1779                    let condition = string_values.join(" or\n        ");
1780                    let _ = writeln!(out, "    try testing.expect(\n        {condition}\n    );");
1781                }
1782            }
1783        }
1784        "matches_regex" => {
1785            let _ = writeln!(out, "    // regex match not yet implemented for Zig");
1786        }
1787        "method_result" => {
1788            let _ = writeln!(out, "    // method_result assertions not yet implemented for Zig");
1789        }
1790        other => {
1791            panic!("Zig e2e generator: unsupported assertion type: {other}");
1792        }
1793    }
1794}
1795
1796/// Convert a `serde_json::Value` to a Zig literal string.
1797fn json_to_zig(value: &serde_json::Value) -> String {
1798    match value {
1799        serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
1800        serde_json::Value::Bool(b) => b.to_string(),
1801        serde_json::Value::Number(n) => n.to_string(),
1802        serde_json::Value::Null => "null".to_string(),
1803        serde_json::Value::Array(arr) => {
1804            let items: Vec<String> = arr.iter().map(json_to_zig).collect();
1805            format!("&.{{{}}}", items.join(", "))
1806        }
1807        serde_json::Value::Object(_) => {
1808            let json_str = serde_json::to_string(value).unwrap_or_default();
1809            format!("\"{}\"", escape_zig(&json_str))
1810        }
1811    }
1812}