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