Skip to main content

alef_e2e/codegen/
zig.rs

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