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