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