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