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