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                    let _ = writeln!(
1024                        out,
1025                        "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
1026                    );
1027                    let _ = writeln!(out, "    defer _parsed.deinit();");
1028                    let _ = writeln!(out, "    const {result_var} = &_parsed.value;");
1029                    for assertion in &fixture.assertions {
1030                        render_json_assertion(out, assertion, result_var, field_resolver, false);
1031                    }
1032                }
1033            }
1034        } else if any_emits_code {
1035            let try_kw = if call_returns_error_union { "try " } else { "" };
1036            let _ = writeln!(
1037                out,
1038                "    const {result_var} = {try_kw}{call_prefix}.{function_name}({args_str});"
1039            );
1040            for assertion in &fixture.assertions {
1041                render_assertion(
1042                    out,
1043                    assertion,
1044                    result_var,
1045                    field_resolver,
1046                    enum_fields,
1047                    result_is_option,
1048                );
1049            }
1050        } else if call_returns_error_union {
1051            let _ = writeln!(out, "    _ = try {call_prefix}.{function_name}({args_str});");
1052        } else {
1053            let _ = writeln!(out, "    _ = {call_prefix}.{function_name}({args_str});");
1054        }
1055    }
1056
1057    let _ = writeln!(out, "}}");
1058}
1059
1060/// Emit the body of a visitor-bearing test. Drives the FFI directly so we
1061/// can attach a `HTMHtmVisitorCallbacks` vtable to the `ConversionOptions`
1062/// handle before calling `htm_convert`. The high-level `convert(html,
1063/// options)` wrapper cannot carry a visitor because the visitor is a Rust
1064/// trait object, not a JSON-encodable field.
1065#[allow(clippy::too_many_arguments)]
1066fn emit_visitor_test_body(
1067    out: &mut String,
1068    fixture_id: &str,
1069    html: &str,
1070    options_value: Option<&serde_json::Value>,
1071    visitor_spec: &crate::fixture::VisitorSpec,
1072    module_name: &str,
1073    assertions: &[Assertion],
1074    expects_error: bool,
1075    field_resolver: &FieldResolver,
1076) {
1077    // Allocator for the JSON-parse of the result blob (and any helper allocs).
1078    let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
1079    let _ = writeln!(out, "    defer _ = gpa.deinit();");
1080    let _ = writeln!(out, "    const allocator = gpa.allocator();");
1081    let _ = writeln!(out);
1082
1083    // 1. Per-fixture visitor struct + callbacks table.
1084    let visitor_block = super::zig_visitors::build_zig_visitor(fixture_id, module_name, visitor_spec);
1085    out.push_str(&visitor_block);
1086
1087    // 2. Materialise the visitor handle (HtmVisitor opaque, attached via
1088    //    htm_options_set_visitor_handle).
1089    let _ = writeln!(
1090        out,
1091        "    const _visitor = {module_name}.c.htm_visitor_create(&_callbacks);"
1092    );
1093    let _ = writeln!(out, "    defer {module_name}.c.htm_visitor_free(_visitor);");
1094
1095    // 3. Options handle: always allocate one (even when the fixture supplies
1096    //    no `options`) so we have somewhere to attach the visitor. The FFI
1097    //    accepts `"{}"` as an empty options JSON.
1098    let options_json = match options_value {
1099        Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1100        None => "{}".to_string(),
1101    };
1102    let escaped_options = escape_zig(&options_json);
1103    let _ = writeln!(
1104        out,
1105        "    const _options_z = try std.heap.c_allocator.dupeZ(u8, \"{escaped_options}\");"
1106    );
1107    let _ = writeln!(out, "    defer std.heap.c_allocator.free(_options_z);");
1108    let _ = writeln!(
1109        out,
1110        "    const _options = {module_name}.c.htm_conversion_options_from_json(_options_z.ptr);"
1111    );
1112    let _ = writeln!(out, "    defer {module_name}.c.htm_conversion_options_free(_options);");
1113    let _ = writeln!(
1114        out,
1115        "    {module_name}.c.htm_options_set_visitor_handle(_options, _visitor);"
1116    );
1117
1118    // 4. HTML buffer + convert call.
1119    let escaped_html = escape_zig(html);
1120    let _ = writeln!(
1121        out,
1122        "    const _html_z = try std.heap.c_allocator.dupeZ(u8, \"{escaped_html}\");"
1123    );
1124    let _ = writeln!(out, "    defer std.heap.c_allocator.free(_html_z);");
1125    let _ = writeln!(
1126        out,
1127        "    const _result = {module_name}.c.htm_convert(_html_z.ptr, _options);"
1128    );
1129
1130    if expects_error {
1131        // Error-path: _result null OR last error code non-zero.
1132        let _ = writeln!(
1133            out,
1134            "    try testing.expect(_result == null or {module_name}.c.htm_last_error_code() != 0);"
1135        );
1136        let _ = writeln!(
1137            out,
1138            "    if (_result) |r| {module_name}.c.htm_conversion_result_free(r);"
1139        );
1140        return;
1141    }
1142
1143    let _ = writeln!(out, "    try testing.expect(_result != null);");
1144    let _ = writeln!(out, "    defer {module_name}.c.htm_conversion_result_free(_result.?);");
1145    let _ = writeln!(
1146        out,
1147        "    const _json_ptr = {module_name}.c.htm_conversion_result_to_json(_result.?);"
1148    );
1149    let _ = writeln!(out, "    defer {module_name}.c.htm_free_string(_json_ptr);");
1150    let _ = writeln!(out, "    const _result_json = std.mem.sliceTo(_json_ptr, 0);");
1151    let _ = writeln!(
1152        out,
1153        "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
1154    );
1155    let _ = writeln!(out, "    defer _parsed.deinit();");
1156    let _ = writeln!(out, "    const result = &_parsed.value;");
1157
1158    for assertion in assertions {
1159        if assertion.assertion_type != "error" {
1160            render_json_assertion(out, assertion, "result", field_resolver, false);
1161        }
1162    }
1163}
1164
1165// ---------------------------------------------------------------------------
1166// JSON-struct assertion rendering (for result_is_json_struct = true)
1167// ---------------------------------------------------------------------------
1168
1169/// Convert a dot-separated field path into a chain of `std.json.Value` lookups.
1170///
1171/// Each segment uses `.object.get("key").?` to traverse the JSON object tree.
1172/// The final segment stops before the leaf-type accessor so callers can append
1173/// the appropriate accessor (`.string`, `.integer`, `.array.items`, etc.).
1174///
1175/// Returns `(base_expr, last_key)` where `base_expr` already includes all
1176/// intermediate `.object.get("…").?` dereferences up to (but not including)
1177/// the leaf, and `last_key` is the last path segment.
1178/// Variant names of `FormatMetadata` (snake_case, from `#[serde(rename_all = "snake_case")]`).
1179/// These appear as typed accessors in fixture paths (e.g. `format.excel.sheet_count`)
1180/// but are NOT JSON keys — `FormatMetadata` is internally tagged so variant fields are
1181/// flattened directly into the `format` object alongside the `format_type` discriminant.
1182const FORMAT_METADATA_VARIANTS: &[&str] = &[
1183    "pdf",
1184    "docx",
1185    "excel",
1186    "email",
1187    "pptx",
1188    "archive",
1189    "image",
1190    "xml",
1191    "text",
1192    "html",
1193    "ocr",
1194    "csv",
1195    "bibtex",
1196    "citation",
1197    "fiction_book",
1198    "dbf",
1199    "jats",
1200    "epub",
1201    "pst",
1202    "code",
1203];
1204
1205fn json_path_expr(result_var: &str, field_path: &str) -> String {
1206    let segments: Vec<&str> = field_path.split('.').collect();
1207    let mut expr = result_var.to_string();
1208    let mut prev_seg: Option<&str> = None;
1209    for seg in &segments {
1210        // Skip variant-name accessor segments that follow a `format` key.
1211        // FormatMetadata is an internally-tagged enum (`#[serde(tag = "format_type")]`),
1212        // so variant fields are flattened directly into the format object — there is no
1213        // intermediate JSON key for the variant name.
1214        if prev_seg == Some("format") && FORMAT_METADATA_VARIANTS.contains(seg) {
1215            prev_seg = Some(seg);
1216            continue;
1217        }
1218        // Handle array accessor notation:
1219        //   "links[]"     → access the array, then first element.
1220        //   "results[0]"  → access the array, then specific index N.
1221        if let Some(key) = seg.strip_suffix("[]") {
1222            expr = format!("{expr}.object.get(\"{key}\").?.array.items[0]");
1223        } else if let Some(bracket_pos) = seg.find('[') {
1224            if let Some(end_pos) = seg.find(']') {
1225                if end_pos > bracket_pos + 1 && end_pos == seg.len() - 1 {
1226                    let key = &seg[..bracket_pos];
1227                    let idx = &seg[bracket_pos + 1..end_pos];
1228                    if idx.chars().all(|c| c.is_ascii_digit()) {
1229                        expr = format!("{expr}.object.get(\"{key}\").?.array.items[{idx}]");
1230                        prev_seg = Some(seg);
1231                        continue;
1232                    }
1233                    // Non-numeric bracket: HashMap<String, _> key access. FRB / serde
1234                    // serialize maps as JSON objects, so `field[key]` resolves to
1235                    // `.object.get("field").?.object.get("key").?`. Used by h2m's
1236                    // `metadata.document.open_graph[title]` alias pattern where
1237                    // `open_graph` is a `HashMap<String, String>`.
1238                    expr = format!("{expr}.object.get(\"{key}\").?.object.get(\"{idx}\").?");
1239                    prev_seg = Some(seg);
1240                    continue;
1241                }
1242            }
1243            expr = format!("{expr}.object.get(\"{seg}\").?");
1244        } else {
1245            expr = format!("{expr}.object.get(\"{seg}\").?");
1246        }
1247        prev_seg = Some(seg);
1248    }
1249    expr
1250}
1251
1252/// Emit a Zig predicate over the `chunks` array of a JSON-parsed extraction
1253/// result. The predicate body should be a Zig expression yielding an
1254/// `?std.json.Value` for each chunk element bound as `c`. When `require_non_empty_string`
1255/// is `true`, the predicate also requires the value to be a non-empty string.
1256fn emit_zig_chunks_predicate(
1257    out: &mut String,
1258    result_var: &str,
1259    assertion_type: &str,
1260    chunk_field_accessor: &str,
1261    field_name: &str,
1262    require_non_empty_string: bool,
1263) {
1264    let _ = writeln!(out, "    {{");
1265    let _ = writeln!(out, "        const _chunks_opt = {result_var}.object.get(\"chunks\");");
1266    let _ = writeln!(out, "        var _all: bool = true;");
1267    let _ = writeln!(out, "        if (_chunks_opt) |_chunks_val| {{");
1268    let _ = writeln!(out, "            if (_chunks_val == .array) {{");
1269    let _ = writeln!(
1270        out,
1271        "                if (_chunks_val.array.items.len == 0) _all = false;"
1272    );
1273    let _ = writeln!(out, "                for (_chunks_val.array.items) |c| {{");
1274    let _ = writeln!(out, "                    if (c != .object) {{ _all = false; break; }}");
1275    let _ = writeln!(out, "                    const _v = {chunk_field_accessor};");
1276    if require_non_empty_string {
1277        let _ = writeln!(
1278            out,
1279            "                    if (_v == null or _v.? != .string or _v.?.string.len == 0) {{ _all = false; break; }}"
1280        );
1281    } else {
1282        let _ = writeln!(
1283            out,
1284            "                    if (_v == null or _v.? == .null) {{ _all = false; break; }}"
1285        );
1286    }
1287    let _ = writeln!(out, "                }}");
1288    let _ = writeln!(out, "            }} else {{ _all = false; }}");
1289    let _ = writeln!(out, "        }} else {{ _all = false; }}");
1290    match assertion_type {
1291        "is_true" => {
1292            let _ = writeln!(out, "        try testing.expect(_all);");
1293        }
1294        "is_false" => {
1295            let _ = writeln!(out, "        try testing.expect(!_all);");
1296        }
1297        _ => {
1298            let _ = writeln!(
1299                out,
1300                "        // skipped: unsupported assertion type on synthetic field '{field_name}'"
1301            );
1302        }
1303    }
1304    let _ = writeln!(out, "    }}");
1305}
1306
1307/// Render a single assertion for a JSON-struct result (result_is_json_struct = true).
1308///
1309/// The `result_var` variable is `*std.json.Value` (pointer to the parsed root object).
1310/// Field paths are traversed via `.object.get("key").?` chains.
1311fn render_json_assertion(
1312    out: &mut String,
1313    assertion: &Assertion,
1314    result_var: &str,
1315    field_resolver: &FieldResolver,
1316    uses_streaming: bool,
1317) {
1318    // Intercept streaming-virtual fields before the result-type validity check,
1319    // but ONLY when the test is actually using the streaming-virtual path.
1320    // When `uses_streaming = false` the `chunks` local is never declared, so
1321    // generating `chunks.items.len` would produce a compile error. Fields like
1322    // "chunks" that happen to share a streaming-virtual name are regular JSON
1323    // fields in non-streaming results and must fall through to the JSON path.
1324    if let Some(f) = &assertion.field {
1325        if uses_streaming && !f.is_empty() && is_streaming_virtual_field(f) {
1326            if let Some(expr) = StreamingFieldResolver::accessor(f, "zig", "chunks") {
1327                match assertion.assertion_type.as_str() {
1328                    "count_min" => {
1329                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1330                            let _ = writeln!(out, "    try testing.expect({expr}.len >= {n});");
1331                        }
1332                    }
1333                    "count_equals" => {
1334                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1335                            let _ = writeln!(out, "    try testing.expectEqual(@as(usize, {n}), {expr}.len);");
1336                        }
1337                    }
1338                    "equals" => {
1339                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1340                            let escaped = escape_zig(s);
1341                            let _ = writeln!(out, "    try testing.expectEqualStrings(\"{escaped}\", {expr});");
1342                        } else if let Some(v) = &assertion.value {
1343                            let zig_val = json_to_zig(v);
1344                            let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {expr});");
1345                        }
1346                    }
1347                    "not_empty" => {
1348                        let _ = writeln!(out, "    try testing.expect({expr}.len > 0);");
1349                    }
1350                    "is_true" => {
1351                        let _ = writeln!(out, "    try testing.expect({expr});");
1352                    }
1353                    "is_false" => {
1354                        let _ = writeln!(out, "    try testing.expect(!{expr});");
1355                    }
1356                    _ => {
1357                        let atype = &assertion.assertion_type;
1358                        let _ = writeln!(
1359                            out,
1360                            "    // streaming virtual field '{f}' assertion '{atype}' not implemented for zig"
1361                        );
1362                    }
1363                }
1364            }
1365            return;
1366        }
1367    }
1368
1369    // Synthetic `embeddings` field on a JSON-array result (e.g. embed_texts
1370    // returns `Vec<Vec<f32>>` → JSON `[[...],[...]]`). The field name is a
1371    // convention from the fixture schema — the JSON value IS the embeddings
1372    // array. Apply the assertion against `result.array.items` directly. The
1373    // synthetic path is only used when no explicit result_fields configure
1374    // `embeddings` as a real struct field.
1375    if let Some(f) = &assertion.field {
1376        if f == "embeddings" && !field_resolver.has_explicit_field("embeddings") {
1377            match assertion.assertion_type.as_str() {
1378                "count_min" => {
1379                    if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1380                        let _ = writeln!(out, "    try testing.expect({result_var}.array.items.len >= {n});");
1381                    }
1382                    return;
1383                }
1384                "count_equals" => {
1385                    if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1386                        let _ = writeln!(
1387                            out,
1388                            "    try testing.expectEqual(@as(usize, {n}), {result_var}.array.items.len);"
1389                        );
1390                    }
1391                    return;
1392                }
1393                "not_empty" => {
1394                    let _ = writeln!(out, "    try testing.expect({result_var}.array.items.len > 0);");
1395                    return;
1396                }
1397                "is_empty" => {
1398                    let _ = writeln!(
1399                        out,
1400                        "    try testing.expectEqual(@as(usize, 0), {result_var}.array.items.len);"
1401                    );
1402                    return;
1403                }
1404                _ => {}
1405            }
1406        }
1407    }
1408
1409    // Synthesised chunk-inspection virtual fields. These are not real JSON
1410    // fields but are derived predicates over the `chunks` array on
1411    // `ExtractionResult`. Other backends (python, ruby, java, etc.) compute
1412    // these inline; zig parses to `std.json.Value`, so we compute them
1413    // against `result.object.get("chunks").?.array`.
1414    if let Some(f) = &assertion.field {
1415        match f.as_str() {
1416            "chunks_have_content" => {
1417                emit_zig_chunks_predicate(
1418                    out,
1419                    result_var,
1420                    assertion.assertion_type.as_str(),
1421                    "c.object.get(\"content\")",
1422                    "chunks_have_content",
1423                    true,
1424                );
1425                return;
1426            }
1427            "chunks_have_heading_context" => {
1428                // `heading_context` is `Option<HeadingContext>` and serde drops
1429                // `None` from the JSON, so chunks without a heading produce no
1430                // key — making an "all chunks have it" predicate spuriously
1431                // fail. Matching the Ruby codegen, skip this synthetic field.
1432                let _ = writeln!(
1433                    out,
1434                    "    // skipped: synthetic field 'chunks_have_heading_context' not derivable from JSON value alone"
1435                );
1436                return;
1437            }
1438            "first_chunk_starts_with_heading" => {
1439                let _ = writeln!(
1440                    out,
1441                    "    // skipped: synthetic field 'first_chunk_starts_with_heading' not derivable from JSON value alone"
1442                );
1443                return;
1444            }
1445            "chunks_have_embeddings" => {
1446                emit_zig_chunks_predicate(
1447                    out,
1448                    result_var,
1449                    assertion.assertion_type.as_str(),
1450                    "c.object.get(\"embedding\")",
1451                    "chunks_have_embeddings",
1452                    false,
1453                );
1454                return;
1455            }
1456            // `keywords` is a fixture alias that does not map cleanly onto the
1457            // serialized JSON ExtractionResult (the real JSON key is
1458            // `extracted_keywords`, which itself may be absent when keyword
1459            // extraction yields nothing). Matching the Python codegen, skip.
1460            "keywords" | "keywords_count" => {
1461                let _ = writeln!(
1462                    out,
1463                    "    // skipped: field '{f}' not available on JSON-struct ExtractionResult"
1464                );
1465                return;
1466            }
1467            _ => {}
1468        }
1469    }
1470
1471    // Skip assertions on fields that don't exist on the result type.
1472    if let Some(f) = &assertion.field {
1473        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1474            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
1475            return;
1476        }
1477    }
1478    // error/not_error are handled at the call level, not assertion level.
1479    if matches!(assertion.assertion_type.as_str(), "not_error" | "error") {
1480        return;
1481    }
1482
1483    let raw_field_path = assertion.field.as_deref().unwrap_or("").trim();
1484    let field_path = if raw_field_path.is_empty() {
1485        raw_field_path.to_string()
1486    } else {
1487        field_resolver.resolve(raw_field_path).to_string()
1488    };
1489    let field_path = field_path.trim();
1490
1491    // "{array_field}.length" → strip suffix; use .array.items.len in the template.
1492    let (field_path_for_expr, is_length_access) = if let Some(parent) = field_path.strip_suffix(".length") {
1493        (parent, true)
1494    } else {
1495        (field_path, false)
1496    };
1497
1498    let field_expr = if field_path_for_expr.is_empty() {
1499        result_var.to_string()
1500    } else {
1501        json_path_expr(result_var, field_path_for_expr)
1502    };
1503
1504    // Special-case `metadata.format` equals-string: `FormatMetadata` is an
1505    // internally-tagged enum serialized as a JSON object (`{"format_type": "image",
1506    // "format": "PNG", ...}`), so `metadata.format` resolves to a JSON object,
1507    // not a string. The fixture asserts the `Display` impl: for Image variant
1508    // emit the inner `format` field; otherwise emit the `format_type` discriminant.
1509    if field_path_for_expr == "metadata.format"
1510        && matches!(
1511            assertion.assertion_type.as_str(),
1512            "equals" | "contains" | "not_empty" | "is_empty" | "starts_with" | "ends_with"
1513        )
1514    {
1515        let base = json_path_expr(result_var, field_path_for_expr);
1516        let _ = writeln!(out, "    {{");
1517        let _ = writeln!(out, "        const _fmt_obj = {base}.object;");
1518        let _ = writeln!(out, "        const _fmt_type = _fmt_obj.get(\"format_type\").?.string;");
1519        let _ = writeln!(
1520            out,
1521            "        const _fmt_display: []const u8 = if (std.mem.eql(u8, _fmt_type, \"image\")) _fmt_obj.get(\"format\").?.string else _fmt_type;"
1522        );
1523        match assertion.assertion_type.as_str() {
1524            "equals" => {
1525                if let Some(serde_json::Value::String(s)) = &assertion.value {
1526                    let escaped = escape_zig(s);
1527                    let _ = writeln!(
1528                        out,
1529                        "        try testing.expectEqualStrings(\"{escaped}\", std.mem.trim(u8, _fmt_display, \" \\n\\r\\t\"));"
1530                    );
1531                }
1532            }
1533            "contains" => {
1534                if let Some(serde_json::Value::String(s)) = &assertion.value {
1535                    let escaped = escape_zig(s);
1536                    let _ = writeln!(
1537                        out,
1538                        "        try testing.expect(std.mem.indexOf(u8, _fmt_display, \"{escaped}\") != null);"
1539                    );
1540                }
1541            }
1542            "starts_with" => {
1543                if let Some(serde_json::Value::String(s)) = &assertion.value {
1544                    let escaped = escape_zig(s);
1545                    let _ = writeln!(
1546                        out,
1547                        "        try testing.expect(std.mem.startsWith(u8, _fmt_display, \"{escaped}\"));"
1548                    );
1549                }
1550            }
1551            "ends_with" => {
1552                if let Some(serde_json::Value::String(s)) = &assertion.value {
1553                    let escaped = escape_zig(s);
1554                    let _ = writeln!(
1555                        out,
1556                        "        try testing.expect(std.mem.endsWith(u8, _fmt_display, \"{escaped}\"));"
1557                    );
1558                }
1559            }
1560            "not_empty" => {
1561                let _ = writeln!(out, "        try testing.expect(_fmt_display.len > 0);");
1562            }
1563            "is_empty" => {
1564                let _ = writeln!(out, "        try testing.expectEqual(@as(usize, 0), _fmt_display.len);");
1565            }
1566            _ => {}
1567        }
1568        let _ = writeln!(out, "    }}");
1569        return;
1570    }
1571
1572    // Compute context variables for the template.
1573    let zig_val = match &assertion.value {
1574        Some(serde_json::Value::String(s)) => format!("\"{}\"", escape_zig(s)),
1575        _ => String::new(),
1576    };
1577    let is_string_val = matches!(&assertion.value, Some(serde_json::Value::String(_)));
1578    let is_bool_val = matches!(&assertion.value, Some(serde_json::Value::Bool(_)));
1579    let bool_val = match &assertion.value {
1580        Some(serde_json::Value::Bool(b)) if *b => "true",
1581        _ => "false",
1582    };
1583    let is_null_val = matches!(&assertion.value, Some(serde_json::Value::Null));
1584    let n = assertion.value.as_ref().map(json_to_zig).unwrap_or_default();
1585    let has_n = assertion.value.as_ref().is_some_and(|v| v.is_number() || v.is_u64());
1586    // Distinguish float vs integer JSON values: `std.json.Value` exposes
1587    // `.integer` (i64) and `.float` (f64) as separate variants. Comparing
1588    // `.integer` against a literal with a fractional part (e.g. `0.9`) is a
1589    // Zig compile error, so the template must select the right tag.
1590    let is_float_val = matches!(&assertion.value, Some(serde_json::Value::Number(n)) if !n.is_i64() && !n.is_u64());
1591    let values_list: Vec<String> = assertion
1592        .values
1593        .as_deref()
1594        .unwrap_or_default()
1595        .iter()
1596        .filter_map(|v| {
1597            if let serde_json::Value::String(s) = v {
1598                Some(format!("\"{}\"", escape_zig(s)))
1599            } else {
1600                None
1601            }
1602        })
1603        .collect();
1604
1605    let rendered = crate::template_env::render(
1606        "zig/json_assertion.jinja",
1607        minijinja::context! {
1608            assertion_type => assertion.assertion_type.as_str(),
1609            field_expr => field_expr,
1610            is_length_access => is_length_access,
1611            zig_val => zig_val,
1612            is_string_val => is_string_val,
1613            is_bool_val => is_bool_val,
1614            bool_val => bool_val,
1615            is_null_val => is_null_val,
1616            n => n,
1617            has_n => has_n,
1618            is_float_val => is_float_val,
1619            values_list => values_list,
1620        },
1621    );
1622    out.push_str(&rendered);
1623}
1624
1625/// Predicate matching `render_assertion`: returns true when the assertion
1626/// would emit at least one statement that references the result variable.
1627fn assertion_emits_code(assertion: &Assertion, field_resolver: &FieldResolver) -> bool {
1628    if let Some(f) = &assertion.field {
1629        if !f.is_empty() && is_streaming_virtual_field(f) {
1630            // Streaming virtual fields always emit code — they are handled in a
1631            // dedicated collect path, not skipped.
1632        } else if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1633            return false;
1634        }
1635    }
1636    matches!(
1637        assertion.assertion_type.as_str(),
1638        "equals"
1639            | "contains"
1640            | "contains_all"
1641            | "not_contains"
1642            | "not_empty"
1643            | "is_empty"
1644            | "starts_with"
1645            | "ends_with"
1646            | "min_length"
1647            | "max_length"
1648            | "count_min"
1649            | "count_equals"
1650            | "is_true"
1651            | "is_false"
1652            | "greater_than"
1653            | "less_than"
1654            | "greater_than_or_equal"
1655            | "less_than_or_equal"
1656            | "contains_any"
1657    )
1658}
1659
1660/// Build setup lines and the argument list for the function call.
1661///
1662/// Returns `(setup_lines, args_str, setup_needs_gpa)` where `setup_needs_gpa`
1663/// is `true` when at least one setup line requires the GPA `allocator` binding.
1664fn build_args_and_setup(
1665    input: &serde_json::Value,
1666    args: &[crate::config::ArgMapping],
1667    fixture_id: &str,
1668    _module_name: &str,
1669) -> (Vec<String>, String, bool) {
1670    if args.is_empty() {
1671        return (Vec::new(), String::new(), false);
1672    }
1673
1674    let mut setup_lines: Vec<String> = Vec::new();
1675    let mut parts: Vec<String> = Vec::new();
1676    let mut setup_needs_gpa = false;
1677
1678    for arg in args {
1679        if arg.arg_type == "mock_url" {
1680            let name = arg.name.clone();
1681            let id_upper = fixture_id.to_uppercase();
1682            setup_lines.push(format!(
1683                "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\"}});"
1684            ));
1685            setup_lines.push(format!("defer allocator.free({name});"));
1686            parts.push(name);
1687            setup_needs_gpa = true;
1688            continue;
1689        }
1690
1691        // Handle args (engine handle): serialize config to JSON string literal, or null.
1692        // The Zig binding accepts ?[]const u8 for engine params (creates handle internally).
1693        if arg.arg_type == "handle" {
1694            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1695            let json_str = match input.get(field) {
1696                Some(serde_json::Value::Null) | None => "null".to_string(),
1697                Some(v) => format!("\"{}\"", escape_zig(&serde_json::to_string(v).unwrap_or_default())),
1698            };
1699            parts.push(json_str);
1700            continue;
1701        }
1702
1703        // The Zig wrapper accepts struct parameters (e.g. `ExtractionConfig`)
1704        // as JSON `[]const u8`, converting them to opaque FFI handles via the
1705        // `<prefix>_<snake>_from_json` helper at the binding layer. Emit the
1706        // fixture's configuration value as a JSON string literal, falling back
1707        // to `"{}"` when the fixture omits a config so callers exercise the
1708        // default path.
1709        if arg.name == "config" && arg.arg_type == "json_object" {
1710            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1711            let json_str = match input.get(field) {
1712                Some(serde_json::Value::Null) | None => "{}".to_string(),
1713                Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1714            };
1715            parts.push(format!("\"{}\"", escape_zig(&json_str)));
1716            continue;
1717        }
1718
1719        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1720        // When `field` is empty or refers to `input` itself (no dotted subfield),
1721        // the entire fixture `input` value is the payload — most commonly for
1722        // `json_object` request bodies (chat/embed/etc.). Without this guard
1723        // `input.get("input")` returns `None` and we fall through to `"{}"`,
1724        // which the FFI rejects as a deserialization error.
1725        let val = if field.is_empty() || field == "input" {
1726            Some(input)
1727        } else {
1728            input.get(field)
1729        };
1730        match val {
1731            None | Some(serde_json::Value::Null) if arg.optional => {
1732                // Zig functions don't have default arguments, so we must
1733                // pass `null` explicitly for every optional parameter.
1734                parts.push("null".to_string());
1735            }
1736            None | Some(serde_json::Value::Null) => {
1737                let default_val = match arg.arg_type.as_str() {
1738                    "string" => "\"\"".to_string(),
1739                    "int" | "integer" => "0".to_string(),
1740                    "float" | "number" => "0.0".to_string(),
1741                    "bool" | "boolean" => "false".to_string(),
1742                    "json_object" => "\"{}\"".to_string(),
1743                    _ => "null".to_string(),
1744                };
1745                parts.push(default_val);
1746            }
1747            Some(v) => {
1748                // For `json_object` arguments other than `config` (handled
1749                // above) the Zig binding accepts a JSON `[]const u8`, so we
1750                // serialize the entire fixture value as a single JSON string
1751                // literal rather than rendering it as a Zig array/struct.
1752                if arg.arg_type == "json_object" {
1753                    let json_str = serde_json::to_string(v).unwrap_or_default();
1754                    parts.push(format!("\"{}\"", escape_zig(&json_str)));
1755                } else if arg.arg_type == "bytes" {
1756                    // `bytes` args are file paths in fixtures — read the file into a
1757                    // local buffer. The cwd is set to test_documents/ at runtime.
1758                    // Zig 0.16 uses std.Io.Dir.cwd() (not std.fs.cwd()) and requires
1759                    // an `io` instance from std.testing.io in test context.
1760                    if let serde_json::Value::String(path) = v {
1761                        let var_name = format!("{}_bytes", arg.name);
1762                        let epath = escape_zig(path);
1763                        setup_lines.push(format!(
1764                            "const {var_name} = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, \"{epath}\", std.heap.c_allocator, .unlimited);"
1765                        ));
1766                        setup_lines.push(format!("defer std.heap.c_allocator.free({var_name});"));
1767                        parts.push(var_name);
1768                    } else {
1769                        parts.push(json_to_zig(v));
1770                    }
1771                } else {
1772                    parts.push(json_to_zig(v));
1773                }
1774            }
1775        }
1776    }
1777
1778    (setup_lines, parts.join(", "), setup_needs_gpa)
1779}
1780
1781fn render_assertion(
1782    out: &mut String,
1783    assertion: &Assertion,
1784    result_var: &str,
1785    field_resolver: &FieldResolver,
1786    enum_fields: &HashSet<String>,
1787    result_is_option: bool,
1788) {
1789    // Bare-result assertions on `?T` (Optional) translate to null-checks instead
1790    // of `.len`. Mirrors the same behaviour in kotlin.rs (bare_result_is_option).
1791    let bare_result_is_option = result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1792    if bare_result_is_option {
1793        match assertion.assertion_type.as_str() {
1794            "is_empty" => {
1795                let _ = writeln!(out, "    try testing.expect({result_var} == null);");
1796                return;
1797            }
1798            "not_empty" => {
1799                let _ = writeln!(out, "    try testing.expect({result_var} != null);");
1800                return;
1801            }
1802            "not_error" => {
1803                // not_error is covered by `try` propagation — the call would have
1804                // returned early on error. Emit a comment-only line so the assertion
1805                // is visible but inert, avoiding contradictory checks when paired
1806                // with `is_empty` on an Optional result.
1807                let _ = writeln!(out, "    // not_error: covered by try propagation");
1808                return;
1809            }
1810            "equals" => {
1811                if let Some(expected) = &assertion.value {
1812                    let zig_val = json_to_zig(expected);
1813                    let _ = writeln!(out, "    try testing.expectEqualStrings({zig_val}, {result_var}.?);");
1814                    return;
1815                }
1816            }
1817            _ => {}
1818        }
1819    }
1820    // Synthetic-field 'embeddings' on a JSON-bytes result (e.g. embed_texts
1821    // returns `Vec<Vec<f32>>` serialised as JSON). Parse the JSON array and
1822    // apply count_min/count_equals/not_empty/is_empty against the element count.
1823    //
1824    // The Zig binding for `Vec<T>`/`result_is_array` returns `[]u8` (the JSON
1825    // payload), not a typed struct — so a fixture field named `embeddings` is
1826    // a convention for "the bare JSON array is the embeddings". Gate on
1827    // `has_explicit_field` rather than `is_valid_for_result`, because the
1828    // latter is permissive (returns true) when `result_fields` is empty —
1829    // which is the common case for these bare-JSON returns and would
1830    // wrongly route through `result.embeddings.len` direct field access on
1831    // a `[]u8` slice.
1832    if let Some(f) = &assertion.field {
1833        if f == "embeddings" && !field_resolver.has_explicit_field(f) {
1834            match assertion.assertion_type.as_str() {
1835                "count_min" | "count_equals" | "not_empty" | "is_empty" => {
1836                    let _ = writeln!(out, "    {{");
1837                    let _ = writeln!(
1838                        out,
1839                        "        var _eparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {result_var}, .{{}});"
1840                    );
1841                    let _ = writeln!(out, "        defer _eparse.deinit();");
1842                    let _ = writeln!(out, "        const _embeddings_len = _eparse.value.array.items.len;");
1843                    match assertion.assertion_type.as_str() {
1844                        "count_min" => {
1845                            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1846                                let _ = writeln!(out, "        try testing.expect(_embeddings_len >= {n});");
1847                            }
1848                        }
1849                        "count_equals" => {
1850                            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1851                                let _ = writeln!(
1852                                    out,
1853                                    "        try testing.expectEqual(@as(usize, {n}), _embeddings_len);"
1854                                );
1855                            }
1856                        }
1857                        "not_empty" => {
1858                            let _ = writeln!(out, "        try testing.expect(_embeddings_len > 0);");
1859                        }
1860                        "is_empty" => {
1861                            let _ = writeln!(out, "        try testing.expectEqual(@as(usize, 0), _embeddings_len);");
1862                        }
1863                        _ => {}
1864                    }
1865                    let _ = writeln!(out, "    }}");
1866                    return;
1867                }
1868                _ => {}
1869            }
1870        }
1871    }
1872
1873    // Synthetic-field 'result' on a bare-string/JSON-bytes return (e.g.
1874    // `detect_mime_type_from_bytes` returns `String` → Zig `[]u8`). The
1875    // fixture convention is `field: "result", contains: "pdf"` meaning the
1876    // bare result itself contains the substring. The Zig binding returns
1877    // `[]u8`, so the substring check applies directly to `result_var`.
1878    if let Some(f) = &assertion.field {
1879        if f == "result" && !field_resolver.has_explicit_field(f) {
1880            match assertion.assertion_type.as_str() {
1881                "contains" => {
1882                    if let Some(expected) = &assertion.value {
1883                        let zig_val = json_to_zig(expected);
1884                        let _ = writeln!(
1885                            out,
1886                            "    try testing.expect(std.mem.indexOf(u8, {result_var}, {zig_val}) != null);"
1887                        );
1888                        return;
1889                    }
1890                }
1891                "not_contains" => {
1892                    if let Some(expected) = &assertion.value {
1893                        let zig_val = json_to_zig(expected);
1894                        let _ = writeln!(
1895                            out,
1896                            "    try testing.expect(std.mem.indexOf(u8, {result_var}, {zig_val}) == null);"
1897                        );
1898                        return;
1899                    }
1900                }
1901                "equals" => {
1902                    if let Some(expected) = &assertion.value {
1903                        let zig_val = json_to_zig(expected);
1904                        let _ = writeln!(out, "    try testing.expectEqualStrings({zig_val}, {result_var});");
1905                        return;
1906                    }
1907                }
1908                "not_empty" => {
1909                    let _ = writeln!(out, "    try testing.expect({result_var}.len > 0);");
1910                    return;
1911                }
1912                "is_empty" => {
1913                    let _ = writeln!(out, "    try testing.expectEqual(@as(usize, 0), {result_var}.len);");
1914                    return;
1915                }
1916                _ => {}
1917            }
1918        }
1919    }
1920
1921    // Skip assertions on fields that don't exist on the result type.
1922    if let Some(f) = &assertion.field {
1923        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1924            let _ = writeln!(out, "    // skipped: field '{{f}}' not available on result type");
1925            return;
1926        }
1927    }
1928
1929    // Determine if this field is an enum type.
1930    let _field_is_enum = assertion
1931        .field
1932        .as_deref()
1933        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1934
1935    let field_expr = match &assertion.field {
1936        Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
1937        _ => result_var.to_string(),
1938    };
1939
1940    match assertion.assertion_type.as_str() {
1941        "equals" => {
1942            if let Some(expected) = &assertion.value {
1943                let zig_val = json_to_zig(expected);
1944                let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {field_expr});");
1945            }
1946        }
1947        "contains" => {
1948            if let Some(expected) = &assertion.value {
1949                let zig_val = json_to_zig(expected);
1950                let _ = writeln!(
1951                    out,
1952                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1953                );
1954            }
1955        }
1956        "contains_all" => {
1957            if let Some(values) = &assertion.values {
1958                for val in values {
1959                    let zig_val = json_to_zig(val);
1960                    let _ = writeln!(
1961                        out,
1962                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1963                    );
1964                }
1965            }
1966        }
1967        "not_contains" => {
1968            if let Some(expected) = &assertion.value {
1969                let zig_val = json_to_zig(expected);
1970                let _ = writeln!(
1971                    out,
1972                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1973                );
1974            } else if let Some(values) = &assertion.values {
1975                // not_contains with a plural `values` list: assert none of the entries
1976                // appear in the field. Emit one expect line per needle so failures
1977                // pinpoint the offending value.
1978                for val in values {
1979                    let zig_val = json_to_zig(val);
1980                    let _ = writeln!(
1981                        out,
1982                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1983                    );
1984                }
1985            }
1986        }
1987        "not_empty" => {
1988            let _ = writeln!(out, "    try testing.expect({field_expr}.len > 0);");
1989        }
1990        "is_empty" => {
1991            let _ = writeln!(out, "    try testing.expect({field_expr}.len == 0);");
1992        }
1993        "starts_with" => {
1994            if let Some(expected) = &assertion.value {
1995                let zig_val = json_to_zig(expected);
1996                let _ = writeln!(
1997                    out,
1998                    "    try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
1999                );
2000            }
2001        }
2002        "ends_with" => {
2003            if let Some(expected) = &assertion.value {
2004                let zig_val = json_to_zig(expected);
2005                let _ = writeln!(
2006                    out,
2007                    "    try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
2008                );
2009            }
2010        }
2011        "min_length" => {
2012            if let Some(val) = &assertion.value {
2013                if let Some(n) = val.as_u64() {
2014                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
2015                }
2016            }
2017        }
2018        "max_length" => {
2019            if let Some(val) = &assertion.value {
2020                if let Some(n) = val.as_u64() {
2021                    let _ = writeln!(out, "    try testing.expect({field_expr}.len <= {n});");
2022                }
2023            }
2024        }
2025        "count_min" => {
2026            if let Some(val) = &assertion.value {
2027                if let Some(n) = val.as_u64() {
2028                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
2029                }
2030            }
2031        }
2032        "count_equals" => {
2033            if let Some(val) = &assertion.value {
2034                if let Some(n) = val.as_u64() {
2035                    // When there is no field (field_expr == result_var), the result
2036                    // is `[]u8` JSON (e.g. batch functions). Parse the JSON array
2037                    // and count its elements; `.len` would give byte count, not item count.
2038                    let has_field = assertion.field.as_deref().is_some_and(|f| !f.is_empty());
2039                    if has_field {
2040                        let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.len);");
2041                    } else {
2042                        let _ = writeln!(out, "    {{");
2043                        let _ = writeln!(
2044                            out,
2045                            "        var _cparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {field_expr}, .{{}});"
2046                        );
2047                        let _ = writeln!(out, "        defer _cparse.deinit();");
2048                        let _ = writeln!(
2049                            out,
2050                            "        try testing.expectEqual({n}, _cparse.value.array.items.len);"
2051                        );
2052                        let _ = writeln!(out, "    }}");
2053                    }
2054                }
2055            }
2056        }
2057        "is_true" => {
2058            let _ = writeln!(out, "    try testing.expect({field_expr});");
2059        }
2060        "is_false" => {
2061            let _ = writeln!(out, "    try testing.expect(!{field_expr});");
2062        }
2063        "not_error" => {
2064            // Already handled by the call succeeding.
2065        }
2066        "error" => {
2067            // Handled at the test function level.
2068        }
2069        "greater_than" => {
2070            if let Some(val) = &assertion.value {
2071                let zig_val = json_to_zig(val);
2072                let _ = writeln!(out, "    try testing.expect({field_expr} > {zig_val});");
2073            }
2074        }
2075        "less_than" => {
2076            if let Some(val) = &assertion.value {
2077                let zig_val = json_to_zig(val);
2078                let _ = writeln!(out, "    try testing.expect({field_expr} < {zig_val});");
2079            }
2080        }
2081        "greater_than_or_equal" => {
2082            if let Some(val) = &assertion.value {
2083                let zig_val = json_to_zig(val);
2084                let _ = writeln!(out, "    try testing.expect({field_expr} >= {zig_val});");
2085            }
2086        }
2087        "less_than_or_equal" => {
2088            if let Some(val) = &assertion.value {
2089                let zig_val = json_to_zig(val);
2090                let _ = writeln!(out, "    try testing.expect({field_expr} <= {zig_val});");
2091            }
2092        }
2093        "contains_any" => {
2094            // At least ONE of the values must be found in the field (OR logic).
2095            if let Some(values) = &assertion.values {
2096                let string_values: Vec<String> = values
2097                    .iter()
2098                    .filter_map(|v| {
2099                        if let serde_json::Value::String(s) = v {
2100                            Some(format!(
2101                                "std.mem.indexOf(u8, {field_expr}, \"{}\") != null",
2102                                escape_zig(s)
2103                            ))
2104                        } else {
2105                            None
2106                        }
2107                    })
2108                    .collect();
2109                if !string_values.is_empty() {
2110                    let condition = string_values.join(" or\n        ");
2111                    let _ = writeln!(out, "    try testing.expect(\n        {condition}\n    );");
2112                }
2113            }
2114        }
2115        "matches_regex" => {
2116            let _ = writeln!(out, "    // regex match not yet implemented for Zig");
2117        }
2118        "method_result" => {
2119            let _ = writeln!(out, "    // method_result assertions not yet implemented for Zig");
2120        }
2121        other => {
2122            panic!("Zig e2e generator: unsupported assertion type: {other}");
2123        }
2124    }
2125}
2126
2127/// Convert a `serde_json::Value` to a Zig literal string.
2128fn json_to_zig(value: &serde_json::Value) -> String {
2129    match value {
2130        serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
2131        serde_json::Value::Bool(b) => b.to_string(),
2132        serde_json::Value::Number(n) => n.to_string(),
2133        serde_json::Value::Null => "null".to_string(),
2134        serde_json::Value::Array(arr) => {
2135            let items: Vec<String> = arr.iter().map(json_to_zig).collect();
2136            format!("&.{{{}}}", items.join(", "))
2137        }
2138        serde_json::Value::Object(_) => {
2139            let json_str = serde_json::to_string(value).unwrap_or_default();
2140            format!("\"{}\"", escape_zig(&json_str))
2141        }
2142    }
2143}