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