Skip to main content

alef_e2e/codegen/
zig.rs

1//! Zig e2e test generator using std.testing.
2//!
3//! Generates `packages/zig/src/<crate>_test.zig` files from JSON fixtures,
4//! driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_zig, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions::toolchain;
14use anyhow::Result;
15use heck::ToSnakeCase;
16use std::collections::HashSet;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23/// Zig e2e code generator.
24pub struct ZigE2eCodegen;
25
26impl E2eCodegen for ZigE2eCodegen {
27    fn generate(
28        &self,
29        groups: &[FixtureGroup],
30        e2e_config: &E2eConfig,
31        config: &ResolvedCrateConfig,
32        _type_defs: &[alef_core::ir::TypeDef],
33    ) -> Result<Vec<GeneratedFile>> {
34        let lang = self.language_name();
35        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37        let mut files = Vec::new();
38
39        // Resolve call config with overrides.
40        let call = &e2e_config.call;
41        let overrides = call.overrides.get(lang);
42        let _module_path = overrides
43            .and_then(|o| o.module.as_ref())
44            .cloned()
45            .unwrap_or_else(|| call.module.clone());
46        let function_name = overrides
47            .and_then(|o| o.function.as_ref())
48            .cloned()
49            .unwrap_or_else(|| call.function.clone());
50        let result_var = &call.result_var;
51
52        // Resolve package config.
53        let zig_pkg = e2e_config.resolve_package("zig");
54        let pkg_path = zig_pkg
55            .as_ref()
56            .and_then(|p| p.path.as_ref())
57            .cloned()
58            .unwrap_or_else(|| "../../packages/zig".to_string());
59        let pkg_name = zig_pkg
60            .as_ref()
61            .and_then(|p| p.name.as_ref())
62            .cloned()
63            .unwrap_or_else(|| config.name.to_snake_case());
64
65        // Generate build.zig.zon (Zig package manifest).
66        files.push(GeneratedFile {
67            path: output_base.join("build.zig.zon"),
68            content: render_build_zig_zon(&pkg_name, &pkg_path, e2e_config.dep_mode),
69            generated_header: false,
70        });
71
72        // Get the module name for imports.
73        let module_name = config.zig_module_name();
74
75        // Generate build.zig - collect test file names first.
76        let field_resolver = FieldResolver::new(
77            &e2e_config.fields,
78            &e2e_config.fields_optional,
79            &e2e_config.result_fields,
80            &e2e_config.fields_array,
81            &e2e_config.fields_method_calls,
82        );
83
84        // Generate test files per category and collect their names.
85        let mut test_filenames: Vec<String> = Vec::new();
86        for group in groups {
87            let active: Vec<&Fixture> = group
88                .fixtures
89                .iter()
90                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
91                .collect();
92
93            if active.is_empty() {
94                continue;
95            }
96
97            let filename = format!("{}_test.zig", sanitize_filename(&group.category));
98            test_filenames.push(filename.clone());
99            let content = render_test_file(
100                &group.category,
101                &active,
102                e2e_config,
103                &function_name,
104                result_var,
105                &e2e_config.call.args,
106                &field_resolver,
107                &e2e_config.fields_enum,
108                &module_name,
109            );
110            files.push(GeneratedFile {
111                path: output_base.join("src").join(filename),
112                content,
113                generated_header: true,
114            });
115        }
116
117        // Generate build.zig with collected test files.
118        files.insert(
119            files
120                .iter()
121                .position(|f| f.path.file_name().is_some_and(|n| n == "build.zig.zon"))
122                .unwrap_or(1),
123            GeneratedFile {
124                path: output_base.join("build.zig"),
125                content: render_build_zig(
126                    &test_filenames,
127                    &pkg_name,
128                    &module_name,
129                    &config.ffi_lib_name(),
130                    &config.ffi_crate_path(),
131                    &e2e_config.test_documents_relative_from(0),
132                ),
133                generated_header: false,
134            },
135        );
136
137        Ok(files)
138    }
139
140    fn language_name(&self) -> &'static str {
141        "zig"
142    }
143}
144
145// ---------------------------------------------------------------------------
146// Rendering
147// ---------------------------------------------------------------------------
148
149fn render_build_zig_zon(pkg_name: &str, pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
150    let dep_block = match dep_mode {
151        crate::config::DependencyMode::Registry => {
152            // For registry mode, use a dummy hash (in real Zig, hash must be computed).
153            format!(
154                r#".{{
155            .url = "https://registry.example.com/{pkg_name}/v0.1.0.tar.gz",
156            .hash = "0000000000000000000000000000000000000000000000000000000000000000",
157        }}"#
158            )
159        }
160        crate::config::DependencyMode::Local => {
161            format!(r#".{{ .path = "{pkg_path}" }}"#)
162        }
163    };
164
165    let min_zig = toolchain::MIN_ZIG_VERSION;
166    // Zig 0.16+ requires a fingerprint of the form (crc32_ieee(name) << 32) | id.
167    let name_bytes: &[u8] = b"e2e_zig";
168    let mut crc: u32 = 0xffff_ffff;
169    for byte in name_bytes {
170        crc ^= *byte as u32;
171        for _ in 0..8 {
172            let mask = (crc & 1).wrapping_neg();
173            crc = (crc >> 1) ^ (0xedb8_8320 & mask);
174        }
175    }
176    let name_crc: u32 = !crc;
177    let mut id: u32 = 0x811c_9dc5;
178    for byte in name_bytes {
179        id ^= *byte as u32;
180        id = id.wrapping_mul(0x0100_0193);
181    }
182    if id == 0 || id == 0xffff_ffff {
183        id = 0x1;
184    }
185    let fingerprint: u64 = ((name_crc as u64) << 32) | (id as u64);
186    format!(
187        r#".{{
188    .name = .e2e_zig,
189    .version = "0.1.0",
190    .fingerprint = 0x{fingerprint:016x},
191    .minimum_zig_version = "{min_zig}",
192    .dependencies = .{{
193        .{pkg_name} = {dep_block},
194    }},
195    .paths = .{{
196        "build.zig",
197        "build.zig.zon",
198        "src",
199    }},
200}}
201"#
202    )
203}
204
205fn render_build_zig(
206    test_filenames: &[String],
207    pkg_name: &str,
208    module_name: &str,
209    ffi_lib_name: &str,
210    ffi_crate_path: &str,
211    test_documents_path: &str,
212) -> String {
213    if test_filenames.is_empty() {
214        return r#"const std = @import("std");
215
216pub fn build(b: *std.Build) void {
217    const target = b.standardTargetOptions(.{});
218    const optimize = b.standardOptimizeOption(.{});
219
220    const test_step = b.step("test", "Run tests");
221}
222"#
223        .to_string();
224    }
225
226    // The Zig build script wires up three names that all derive from the
227    // crate config:
228    //   * `ffi_lib_name`     — the dynamic library to link (e.g. `mylib_ffi`).
229    //   * `pkg_name`         — the Zig package directory and source file stem
230    //                          under `packages/zig/src/<pkg_name>.zig`.
231    //   * `module_name`      — the Zig `@import("...")` identifier other test
232    //                          files use to import the binding module.
233    // Callers pass these in resolved form so this function never embeds a
234    // downstream crate's name.
235    let mut content = String::from("const std = @import(\"std\");\n\npub fn build(b: *std.Build) void {\n");
236    content.push_str("    const target = b.standardTargetOptions(.{});\n");
237    content.push_str("    const optimize = b.standardOptimizeOption(.{});\n");
238    content.push_str("    const test_step = b.step(\"test\", \"Run tests\");\n");
239    let _ = writeln!(
240        content,
241        "    const ffi_path = b.option([]const u8, \"ffi_path\", \"Path to directory containing lib{ffi_lib_name}\") orelse \"../../target/debug\";"
242    );
243    let _ = writeln!(
244        content,
245        "    const ffi_include = b.option([]const u8, \"ffi_include_path\", \"Path to directory containing FFI header\") orelse \"{ffi_crate_path}/include\";"
246    );
247    let _ = writeln!(content);
248    let _ = writeln!(
249        content,
250        "    const {module_name}_module = b.addModule(\"{module_name}\", .{{"
251    );
252    let _ = writeln!(
253        content,
254        "        .root_source_file = b.path(\"../../packages/zig/src/{pkg_name}.zig\"),"
255    );
256    content.push_str("        .target = target,\n");
257    content.push_str("        .optimize = optimize,\n");
258    content.push_str("    });\n");
259    let _ = writeln!(
260        content,
261        "    {module_name}_module.addLibraryPath(.{{ .cwd_relative = ffi_path }});"
262    );
263    let _ = writeln!(
264        content,
265        "    {module_name}_module.addIncludePath(.{{ .cwd_relative = ffi_include }});"
266    );
267    let _ = writeln!(
268        content,
269        "    {module_name}_module.linkSystemLibrary(\"{ffi_lib_name}\", .{{}});"
270    );
271    let _ = writeln!(content);
272
273    for filename in test_filenames {
274        // Convert filename like "basic_test.zig" to a test name
275        let test_name = filename.trim_end_matches("_test.zig");
276        content.push_str(&format!("    const {test_name}_module = b.createModule(.{{\n"));
277        content.push_str(&format!("        .root_source_file = b.path(\"src/{filename}\"),\n"));
278        content.push_str("        .target = target,\n");
279        content.push_str("        .optimize = optimize,\n");
280        content.push_str("    });\n");
281        content.push_str(&format!(
282            "    {test_name}_module.addImport(\"{module_name}\", {module_name}_module);\n"
283        ));
284        content.push_str(&format!("    const {test_name}_tests = b.addTest(.{{\n"));
285        content.push_str(&format!("        .root_module = {test_name}_module,\n"));
286        content.push_str("    });\n");
287        content.push_str(&format!(
288            "    const {test_name}_run = b.addRunArtifact({test_name}_tests);\n"
289        ));
290        content.push_str(&format!(
291            "    {test_name}_run.setCwd(b.path(\"{test_documents_path}\"));\n"
292        ));
293        content.push_str(&format!("    test_step.dependOn(&{test_name}_run.step);\n\n"));
294    }
295
296    content.push_str("}\n");
297    content
298}
299
300// ---------------------------------------------------------------------------
301// HTTP server test rendering — shared-driver integration
302// ---------------------------------------------------------------------------
303
304/// Renderer that emits Zig `test "..." { ... }` blocks targeting a mock server
305/// via `std.http.Client`. Satisfies [`client::TestClientRenderer`] so the shared
306/// [`client::http_call::render_http_test`] driver drives the call sequence.
307struct ZigTestClientRenderer;
308
309impl client::TestClientRenderer for ZigTestClientRenderer {
310    fn language_name(&self) -> &'static str {
311        "zig"
312    }
313
314    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
315        if let Some(reason) = skip_reason {
316            let _ = writeln!(out, "test \"{fn_name}\" {{");
317            let _ = writeln!(out, "    // {description}");
318            let _ = writeln!(out, "    // skipped: {reason}");
319            let _ = writeln!(out, "    return error.SkipZigTest;");
320        } else {
321            let _ = writeln!(out, "test \"{fn_name}\" {{");
322            let _ = writeln!(out, "    // {description}");
323        }
324    }
325
326    fn render_test_close(&self, out: &mut String) {
327        let _ = writeln!(out, "}}");
328    }
329
330    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
331        let method = ctx.method.to_uppercase();
332        let fixture_id = ctx.path.trim_start_matches("/fixtures/");
333
334        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
335        let _ = writeln!(out, "    defer _ = gpa.deinit();");
336        let _ = writeln!(out, "    const allocator = gpa.allocator();");
337
338        let _ = writeln!(out, "    var url_buf: [512]u8 = undefined;");
339        let _ = writeln!(
340            out,
341            "    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\"}});"
342        );
343
344        // Headers
345        if !ctx.headers.is_empty() {
346            let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
347            header_pairs.sort_by_key(|(k, _)| k.as_str());
348            let _ = writeln!(out, "    const headers = [_]std.http.Header{{");
349            for (k, v) in &header_pairs {
350                let ek = escape_zig(k);
351                let ev = escape_zig(v);
352                let _ = writeln!(out, "        .{{ .name = \"{ek}\", .value = \"{ev}\" }},");
353            }
354            let _ = writeln!(out, "    }};");
355        }
356
357        // Body
358        if let Some(body) = ctx.body {
359            let json_str = serde_json::to_string(body).unwrap_or_default();
360            let escaped = escape_zig(&json_str);
361            let _ = writeln!(out, "    const body_bytes: []const u8 = \"{escaped}\";");
362        }
363
364        let headers_arg = if ctx.headers.is_empty() { "&.{}" } else { "&headers" };
365        let has_body = ctx.body.is_some();
366
367        let _ = writeln!(
368            out,
369            "    var http_client = std.http.Client{{ .allocator = allocator }};"
370        );
371        let _ = writeln!(out, "    defer http_client.deinit();");
372        let _ = writeln!(out, "    var response_body = std.ArrayList(u8).init(allocator);");
373        let _ = writeln!(out, "    defer response_body.deinit();");
374
375        let method_zig = match method.as_str() {
376            "GET" => ".GET",
377            "POST" => ".POST",
378            "PUT" => ".PUT",
379            "DELETE" => ".DELETE",
380            "PATCH" => ".PATCH",
381            "HEAD" => ".HEAD",
382            "OPTIONS" => ".OPTIONS",
383            _ => ".GET",
384        };
385
386        let payload_field = if has_body { ", .payload = body_bytes" } else { "" };
387        let _ = writeln!(
388            out,
389            "    const {rv} = try http_client.fetch(.{{ .location = .{{ .url = url }}, .method = {method_zig}, .extra_headers = {headers_arg}{payload_field}, .response_storage = .{{ .dynamic = &response_body }} }});",
390            rv = ctx.response_var,
391        );
392    }
393
394    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
395        let _ = writeln!(
396            out,
397            "    try testing.expectEqual(@as(u10, {status}), @intFromEnum({response_var}.status));"
398        );
399    }
400
401    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
402        let ename = escape_zig(&name.to_lowercase());
403        match expected {
404            "<<present>>" => {
405                let _ = writeln!(
406                    out,
407                    "    // assert header '{ename}' is present (header inspection not yet implemented)"
408                );
409            }
410            "<<absent>>" => {
411                let _ = writeln!(
412                    out,
413                    "    // assert header '{ename}' is absent (header inspection not yet implemented)"
414                );
415            }
416            "<<uuid>>" => {
417                let _ = writeln!(
418                    out,
419                    "    // assert header '{ename}' matches UUID pattern (header inspection not yet implemented)"
420                );
421            }
422            exact => {
423                let evalue = escape_zig(exact);
424                let _ = writeln!(
425                    out,
426                    "    // assert header '{ename}' == \"{evalue}\" (header inspection not yet implemented)"
427                );
428            }
429        }
430    }
431
432    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
433        let json_str = serde_json::to_string(expected).unwrap_or_default();
434        let escaped = escape_zig(&json_str);
435        let _ = writeln!(
436            out,
437            "    try testing.expectEqualStrings(\"{escaped}\", response_body.items);"
438        );
439    }
440
441    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
442        if let Some(obj) = expected.as_object() {
443            for (key, val) in obj {
444                let ekey = escape_zig(key);
445                let eval = escape_zig(&serde_json::to_string(val).unwrap_or_default());
446                let _ = writeln!(
447                    out,
448                    "    // assert body contains field \"{ekey}\" = \"{eval}\" (partial JSON not yet implemented)"
449                );
450            }
451        }
452    }
453
454    fn render_assert_validation_errors(
455        &self,
456        out: &mut String,
457        _response_var: &str,
458        errors: &[crate::fixture::ValidationErrorExpectation],
459    ) {
460        for ve in errors {
461            let loc = ve.loc.join(".");
462            let escaped_loc = escape_zig(&loc);
463            let escaped_msg = escape_zig(&ve.msg);
464            let _ = writeln!(
465                out,
466                "    // assert validation error at \"{escaped_loc}\": \"{escaped_msg}\" (not yet implemented)"
467            );
468        }
469    }
470}
471
472/// Render a Zig `test "..." { ... }` block for an HTTP server fixture.
473///
474/// Delegates to the shared [`client::http_call::render_http_test`] driver via
475/// [`ZigTestClientRenderer`].
476fn render_http_test_case(out: &mut String, fixture: &Fixture) {
477    client::http_call::render_http_test(out, &ZigTestClientRenderer, fixture);
478}
479
480// ---------------------------------------------------------------------------
481// Function-call test rendering
482// ---------------------------------------------------------------------------
483
484#[allow(clippy::too_many_arguments)]
485fn render_test_file(
486    category: &str,
487    fixtures: &[&Fixture],
488    e2e_config: &E2eConfig,
489    function_name: &str,
490    result_var: &str,
491    args: &[crate::config::ArgMapping],
492    field_resolver: &FieldResolver,
493    enum_fields: &HashSet<String>,
494    module_name: &str,
495) -> String {
496    let mut out = String::new();
497    out.push_str(&hash::header(CommentStyle::DoubleSlash));
498    let _ = writeln!(out, "const std = @import(\"std\");");
499    let _ = writeln!(out, "const testing = std.testing;");
500    let _ = writeln!(out, "const {module_name} = @import(\"{module_name}\");");
501    let _ = writeln!(out);
502
503    let _ = writeln!(out, "// E2e tests for category: {category}");
504    let _ = writeln!(out);
505
506    for fixture in fixtures {
507        if fixture.http.is_some() {
508            render_http_test_case(&mut out, fixture);
509        } else {
510            render_test_fn(
511                &mut out,
512                fixture,
513                e2e_config,
514                function_name,
515                result_var,
516                args,
517                field_resolver,
518                enum_fields,
519                module_name,
520            );
521        }
522        let _ = writeln!(out);
523    }
524
525    out
526}
527
528#[allow(clippy::too_many_arguments)]
529fn render_test_fn(
530    out: &mut String,
531    fixture: &Fixture,
532    e2e_config: &E2eConfig,
533    _function_name: &str,
534    _result_var: &str,
535    _args: &[crate::config::ArgMapping],
536    field_resolver: &FieldResolver,
537    enum_fields: &HashSet<String>,
538    module_name: &str,
539) {
540    // Resolve per-fixture call config.
541    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
542    let lang = "zig";
543    let call_overrides = call_config.overrides.get(lang);
544    let function_name = call_overrides
545        .and_then(|o| o.function.as_ref())
546        .cloned()
547        .unwrap_or_else(|| call_config.function.clone());
548    let result_var = &call_config.result_var;
549    let args = &call_config.args;
550    // Client factory: when set, the test instantiates a client object via
551    // `module.factory_fn(...)` and calls methods on the instance rather than
552    // calling top-level package functions directly.
553    // Mirrors the go codegen pattern (go.rs:981-1028 / CallOverride.client_factory).
554    let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
555        e2e_config
556            .call
557            .overrides
558            .get(lang)
559            .and_then(|o| o.client_factory.as_deref())
560    });
561
562    // When `result_is_json_struct = true`, the Zig function returns `[]u8` JSON.
563    // The test parses it with `std.json.parseFromSlice(std.json.Value, ...)` and
564    // traverses the dynamic JSON object for field assertions.
565    //
566    // Client-factory methods on opaque handles always return JSON `[]u8` because
567    // the zig backend serializes struct results via the FFI's `*_to_json` helper
568    // (see alef-backend-zig/src/gen_bindings/opaque_handles.rs). Force the flag
569    // on whenever a client_factory is in play so the test path parses the JSON
570    // result rather than attempting direct field access on `[]u8`.
571    let result_is_json_struct = call_overrides.is_some_and(|o| o.result_is_json_struct) || client_factory.is_some();
572
573    let test_name = fixture.id.to_snake_case();
574    let description = &fixture.description;
575    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
576
577    let (setup_lines, args_str, setup_needs_gpa) = build_args_and_setup(&fixture.input, args, &fixture.id, module_name);
578
579    // Pre-compute whether any assertion will emit code that references `result` /
580    // `allocator`. Used to decide whether to emit the GPA allocator binding.
581    let any_happy_emits_code = fixture
582        .assertions
583        .iter()
584        .any(|a| assertion_emits_code(a, field_resolver));
585    let any_non_error_emits_code = fixture
586        .assertions
587        .iter()
588        .filter(|a| a.assertion_type != "error")
589        .any(|a| assertion_emits_code(a, field_resolver));
590
591    let _ = writeln!(out, "test \"{test_name}\" {{");
592    let _ = writeln!(out, "    // {description}");
593
594    // Emit GPA allocator only when it will actually be used: setup lines that
595    // need GPA allocation (mock_url), or a JSON-struct result path where the test
596    // will call `std.json.parseFromSlice`. The binding is not needed for
597    // error-only paths or tests with no field assertions.
598    // Note: `bytes` arg setup uses c_allocator directly and does NOT require GPA.
599    let needs_gpa = setup_needs_gpa
600        || (result_is_json_struct && !expects_error && any_happy_emits_code)
601        || (result_is_json_struct && expects_error && any_non_error_emits_code);
602    if needs_gpa {
603        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
604        let _ = writeln!(out, "    defer _ = gpa.deinit();");
605        let _ = writeln!(out, "    const allocator = gpa.allocator();");
606        let _ = writeln!(out);
607    }
608
609    for line in &setup_lines {
610        let _ = writeln!(out, "    {line}");
611    }
612
613    // Client factory: when configured, instantiate a client object via the named
614    // constructor function and call the method on the instance.
615    // The client is pointed at MOCK_SERVER_URL/fixtures/<id> (mirrors go.rs:981-1028).
616    // When not configured, fall back to calling the top-level package function directly.
617    let call_prefix = if let Some(factory) = client_factory {
618        let fixture_id = &fixture.id;
619        let _ = writeln!(
620            out,
621            "    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);"
622        );
623        let _ = writeln!(out, "    defer std.heap.c_allocator.free(_mock_url);");
624        let _ = writeln!(
625            out,
626            "    var _client = try {module_name}.{factory}(\"test-key\", _mock_url, null, null, null);"
627        );
628        let _ = writeln!(out, "    defer _client.free();");
629        "_client".to_string()
630    } else {
631        module_name.to_string()
632    };
633
634    if expects_error {
635        // Error-path test: use error union syntax `!T` and try-catch.
636        // Async functions execute via tokio::runtime::block_on in the FFI shim,
637        // so the call site is synchronous from Zig's perspective.
638        if result_is_json_struct {
639            let _ = writeln!(
640                out,
641                "    const _result_json = {call_prefix}.{function_name}({args_str}) catch {{"
642            );
643        } else {
644            let _ = writeln!(
645                out,
646                "    const result = {call_prefix}.{function_name}({args_str}) catch {{"
647            );
648        }
649        let _ = writeln!(out, "        try testing.expect(true); // Error occurred as expected");
650        let _ = writeln!(out, "        return;");
651        let _ = writeln!(out, "    }};");
652        // Whether any non-error assertion will emit code that references `result`.
653        // If not, we must explicitly discard `result` to satisfy Zig's
654        // strict-unused-locals rule.
655        let any_emits_code = fixture
656            .assertions
657            .iter()
658            .filter(|a| a.assertion_type != "error")
659            .any(|a| assertion_emits_code(a, field_resolver));
660        if result_is_json_struct && any_emits_code {
661            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
662            let _ = writeln!(
663                out,
664                "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
665            );
666            let _ = writeln!(out, "    defer _parsed.deinit();");
667            let _ = writeln!(out, "    const {result_var} = &_parsed.value;");
668            let _ = writeln!(out, "    // Perform success assertions if any");
669            for assertion in &fixture.assertions {
670                if assertion.assertion_type != "error" {
671                    render_json_assertion(out, assertion, result_var, field_resolver);
672                }
673            }
674        } else if result_is_json_struct {
675            let _ = writeln!(out, "    _ = _result_json;");
676        } else if any_emits_code {
677            let _ = writeln!(out, "    // Perform success assertions if any");
678            for assertion in &fixture.assertions {
679                if assertion.assertion_type != "error" {
680                    render_assertion(out, assertion, result_var, field_resolver, enum_fields);
681                }
682            }
683        } else {
684            let _ = writeln!(out, "    _ = result;");
685        }
686    } else if fixture.assertions.is_empty() {
687        // No assertions: emit a call to verify compilation.
688        if result_is_json_struct {
689            let _ = writeln!(
690                out,
691                "    const _result_json = try {call_prefix}.{function_name}({args_str});"
692            );
693            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
694        } else {
695            let _ = writeln!(out, "    _ = try {call_prefix}.{function_name}({args_str});");
696        }
697    } else {
698        // Happy path: call and assert. Detect whether any assertion actually
699        // emits code that references `result` (some — like `not_error` — emit
700        // nothing) so we don't leave an unused local, which Zig 0.16 rejects.
701        let any_emits_code = fixture
702            .assertions
703            .iter()
704            .any(|a| assertion_emits_code(a, field_resolver));
705        if result_is_json_struct {
706            // JSON struct path: parse result JSON and access fields dynamically.
707            let _ = writeln!(
708                out,
709                "    const _result_json = try {call_prefix}.{function_name}({args_str});"
710            );
711            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
712            if any_emits_code {
713                let _ = writeln!(
714                    out,
715                    "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
716                );
717                let _ = writeln!(out, "    defer _parsed.deinit();");
718                let _ = writeln!(out, "    const {result_var} = &_parsed.value;");
719                for assertion in &fixture.assertions {
720                    render_json_assertion(out, assertion, result_var, field_resolver);
721                }
722            }
723        } else if any_emits_code {
724            let _ = writeln!(
725                out,
726                "    const {result_var} = try {call_prefix}.{function_name}({args_str});"
727            );
728            for assertion in &fixture.assertions {
729                render_assertion(out, assertion, result_var, field_resolver, enum_fields);
730            }
731        } else {
732            let _ = writeln!(out, "    _ = try {call_prefix}.{function_name}({args_str});");
733        }
734    }
735
736    let _ = writeln!(out, "}}");
737}
738
739// ---------------------------------------------------------------------------
740// JSON-struct assertion rendering (for result_is_json_struct = true)
741// ---------------------------------------------------------------------------
742
743/// Convert a dot-separated field path into a chain of `std.json.Value` lookups.
744///
745/// Each segment uses `.object.get("key").?` to traverse the JSON object tree.
746/// The final segment stops before the leaf-type accessor so callers can append
747/// the appropriate accessor (`.string`, `.integer`, `.array.items`, etc.).
748///
749/// Returns `(base_expr, last_key)` where `base_expr` already includes all
750/// intermediate `.object.get("…").?` dereferences up to (but not including)
751/// the leaf, and `last_key` is the last path segment.
752/// Variant names of `FormatMetadata` (snake_case, from `#[serde(rename_all = "snake_case")]`).
753/// These appear as typed accessors in fixture paths (e.g. `format.excel.sheet_count`)
754/// but are NOT JSON keys — `FormatMetadata` is internally tagged so variant fields are
755/// flattened directly into the `format` object alongside the `format_type` discriminant.
756const FORMAT_METADATA_VARIANTS: &[&str] = &[
757    "pdf",
758    "docx",
759    "excel",
760    "email",
761    "pptx",
762    "archive",
763    "image",
764    "xml",
765    "text",
766    "html",
767    "ocr",
768    "csv",
769    "bibtex",
770    "citation",
771    "fiction_book",
772    "dbf",
773    "jats",
774    "epub",
775    "pst",
776    "code",
777];
778
779fn json_path_expr(result_var: &str, field_path: &str) -> String {
780    let segments: Vec<&str> = field_path.split('.').collect();
781    let mut expr = result_var.to_string();
782    let mut prev_seg: Option<&str> = None;
783    for seg in &segments {
784        // Skip variant-name accessor segments that follow a `format` key.
785        // FormatMetadata is an internally-tagged enum (`#[serde(tag = "format_type")]`),
786        // so variant fields are flattened directly into the format object — there is no
787        // intermediate JSON key for the variant name.
788        if prev_seg == Some("format") && FORMAT_METADATA_VARIANTS.contains(seg) {
789            prev_seg = Some(seg);
790            continue;
791        }
792        // Handle array accessor notation: "links[]" → access the array, then first element.
793        if let Some(key) = seg.strip_suffix("[]") {
794            expr = format!("{expr}.object.get(\"{key}\").?.array.items[0]");
795        } else {
796            expr = format!("{expr}.object.get(\"{seg}\").?");
797        }
798        prev_seg = Some(seg);
799    }
800    expr
801}
802
803/// Render a single assertion for a JSON-struct result (result_is_json_struct = true).
804///
805/// The `result_var` variable is `*std.json.Value` (pointer to the parsed root object).
806/// Field paths are traversed via `.object.get("key").?` chains.
807fn render_json_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
808    // Skip assertions on fields that don't exist on the result type.
809    if let Some(f) = &assertion.field {
810        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
811            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
812            return;
813        }
814    }
815    // error/not_error are handled at the call level, not assertion level.
816    if matches!(assertion.assertion_type.as_str(), "not_error" | "error") {
817        return;
818    }
819
820    let raw_field_path = assertion.field.as_deref().unwrap_or("").trim();
821    let field_path = if raw_field_path.is_empty() {
822        raw_field_path.to_string()
823    } else {
824        field_resolver.resolve(raw_field_path).to_string()
825    };
826    let field_path = field_path.trim();
827
828    // "{array_field}.length" → strip suffix; use .array.items.len in the template.
829    let (field_path_for_expr, is_length_access) = if let Some(parent) = field_path.strip_suffix(".length") {
830        (parent, true)
831    } else {
832        (field_path, false)
833    };
834
835    let field_expr = if field_path_for_expr.is_empty() {
836        result_var.to_string()
837    } else {
838        json_path_expr(result_var, field_path_for_expr)
839    };
840
841    // Compute context variables for the template.
842    let zig_val = match &assertion.value {
843        Some(serde_json::Value::String(s)) => format!("\"{}\"", escape_zig(s)),
844        _ => String::new(),
845    };
846    let is_string_val = matches!(&assertion.value, Some(serde_json::Value::String(_)));
847    let is_bool_val = matches!(&assertion.value, Some(serde_json::Value::Bool(_)));
848    let bool_val = match &assertion.value {
849        Some(serde_json::Value::Bool(b)) if *b => "true",
850        _ => "false",
851    };
852    let is_null_val = matches!(&assertion.value, Some(serde_json::Value::Null));
853    let n = assertion.value.as_ref().map(json_to_zig).unwrap_or_default();
854    let has_n = assertion.value.as_ref().is_some_and(|v| v.is_number() || v.is_u64());
855    let values_list: Vec<String> = assertion
856        .values
857        .as_deref()
858        .unwrap_or_default()
859        .iter()
860        .filter_map(|v| {
861            if let serde_json::Value::String(s) = v {
862                Some(format!("\"{}\"", escape_zig(s)))
863            } else {
864                None
865            }
866        })
867        .collect();
868
869    let rendered = crate::template_env::render(
870        "zig/json_assertion.jinja",
871        minijinja::context! {
872            assertion_type => assertion.assertion_type.as_str(),
873            field_expr => field_expr,
874            is_length_access => is_length_access,
875            zig_val => zig_val,
876            is_string_val => is_string_val,
877            is_bool_val => is_bool_val,
878            bool_val => bool_val,
879            is_null_val => is_null_val,
880            n => n,
881            has_n => has_n,
882            values_list => values_list,
883        },
884    );
885    out.push_str(&rendered);
886}
887
888/// Predicate matching `render_assertion`: returns true when the assertion
889/// would emit at least one statement that references the result variable.
890fn assertion_emits_code(assertion: &Assertion, field_resolver: &FieldResolver) -> bool {
891    if let Some(f) = &assertion.field {
892        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
893            return false;
894        }
895    }
896    matches!(
897        assertion.assertion_type.as_str(),
898        "equals"
899            | "contains"
900            | "contains_all"
901            | "not_contains"
902            | "not_empty"
903            | "is_empty"
904            | "starts_with"
905            | "ends_with"
906            | "min_length"
907            | "max_length"
908            | "count_min"
909            | "count_equals"
910            | "is_true"
911            | "is_false"
912            | "greater_than"
913            | "less_than"
914            | "greater_than_or_equal"
915            | "less_than_or_equal"
916            | "contains_any"
917    )
918}
919
920/// Build setup lines and the argument list for the function call.
921///
922/// Returns `(setup_lines, args_str, setup_needs_gpa)` where `setup_needs_gpa`
923/// is `true` when at least one setup line requires the GPA `allocator` binding.
924fn build_args_and_setup(
925    input: &serde_json::Value,
926    args: &[crate::config::ArgMapping],
927    fixture_id: &str,
928    _module_name: &str,
929) -> (Vec<String>, String, bool) {
930    if args.is_empty() {
931        return (Vec::new(), String::new(), false);
932    }
933
934    let mut setup_lines: Vec<String> = Vec::new();
935    let mut parts: Vec<String> = Vec::new();
936    let mut setup_needs_gpa = false;
937
938    for arg in args {
939        if arg.arg_type == "mock_url" {
940            let name = arg.name.clone();
941            let id_upper = fixture_id.to_uppercase();
942            setup_lines.push(format!(
943                "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\"}});"
944            ));
945            setup_lines.push(format!("defer allocator.free({name});"));
946            parts.push(name);
947            setup_needs_gpa = true;
948            continue;
949        }
950
951        // Handle args (engine handle): serialize config to JSON string literal, or null.
952        // The Zig binding accepts ?[]const u8 for engine params (creates handle internally).
953        if arg.arg_type == "handle" {
954            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
955            let json_str = match input.get(field) {
956                Some(serde_json::Value::Null) | None => "null".to_string(),
957                Some(v) => format!("\"{}\"", escape_zig(&serde_json::to_string(v).unwrap_or_default())),
958            };
959            parts.push(json_str);
960            continue;
961        }
962
963        // The Zig wrapper accepts struct parameters (e.g. `ExtractionConfig`)
964        // as JSON `[]const u8`, converting them to opaque FFI handles via the
965        // `<prefix>_<snake>_from_json` helper at the binding layer. Emit the
966        // fixture's configuration value as a JSON string literal, falling back
967        // to `"{}"` when the fixture omits a config so callers exercise the
968        // default path.
969        if arg.name == "config" && arg.arg_type == "json_object" {
970            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
971            let json_str = match input.get(field) {
972                Some(serde_json::Value::Null) | None => "{}".to_string(),
973                Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
974            };
975            parts.push(format!("\"{}\"", escape_zig(&json_str)));
976            continue;
977        }
978
979        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
980        let val = input.get(field);
981        match val {
982            None | Some(serde_json::Value::Null) if arg.optional => {
983                // Zig functions don't have default arguments, so we must
984                // pass `null` explicitly for every optional parameter.
985                parts.push("null".to_string());
986            }
987            None | Some(serde_json::Value::Null) => {
988                let default_val = match arg.arg_type.as_str() {
989                    "string" => "\"\"".to_string(),
990                    "int" | "integer" => "0".to_string(),
991                    "float" | "number" => "0.0".to_string(),
992                    "bool" | "boolean" => "false".to_string(),
993                    "json_object" => "\"{}\"".to_string(),
994                    _ => "null".to_string(),
995                };
996                parts.push(default_val);
997            }
998            Some(v) => {
999                // For `json_object` arguments other than `config` (handled
1000                // above) the Zig binding accepts a JSON `[]const u8`, so we
1001                // serialize the entire fixture value as a single JSON string
1002                // literal rather than rendering it as a Zig array/struct.
1003                if arg.arg_type == "json_object" {
1004                    let json_str = serde_json::to_string(v).unwrap_or_default();
1005                    parts.push(format!("\"{}\"", escape_zig(&json_str)));
1006                } else if arg.arg_type == "bytes" {
1007                    // `bytes` args are file paths in fixtures — read the file into a
1008                    // local buffer. The cwd is set to test_documents/ at runtime.
1009                    // Zig 0.16 uses std.Io.Dir.cwd() (not std.fs.cwd()) and requires
1010                    // an `io` instance from std.testing.io in test context.
1011                    if let serde_json::Value::String(path) = v {
1012                        let var_name = format!("{}_bytes", arg.name);
1013                        let epath = escape_zig(path);
1014                        setup_lines.push(format!(
1015                            "const {var_name} = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, \"{epath}\", std.heap.c_allocator, .unlimited);"
1016                        ));
1017                        setup_lines.push(format!("defer std.heap.c_allocator.free({var_name});"));
1018                        parts.push(var_name);
1019                    } else {
1020                        parts.push(json_to_zig(v));
1021                    }
1022                } else {
1023                    parts.push(json_to_zig(v));
1024                }
1025            }
1026        }
1027    }
1028
1029    (setup_lines, parts.join(", "), setup_needs_gpa)
1030}
1031
1032fn render_assertion(
1033    out: &mut String,
1034    assertion: &Assertion,
1035    result_var: &str,
1036    field_resolver: &FieldResolver,
1037    enum_fields: &HashSet<String>,
1038) {
1039    // Skip assertions on fields that don't exist on the result type.
1040    if let Some(f) = &assertion.field {
1041        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1042            let _ = writeln!(out, "    // skipped: field '{{f}}' not available on result type");
1043            return;
1044        }
1045    }
1046
1047    // Determine if this field is an enum type.
1048    let _field_is_enum = assertion
1049        .field
1050        .as_deref()
1051        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1052
1053    let field_expr = match &assertion.field {
1054        Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
1055        _ => result_var.to_string(),
1056    };
1057
1058    match assertion.assertion_type.as_str() {
1059        "equals" => {
1060            if let Some(expected) = &assertion.value {
1061                let zig_val = json_to_zig(expected);
1062                let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {field_expr});");
1063            }
1064        }
1065        "contains" => {
1066            if let Some(expected) = &assertion.value {
1067                let zig_val = json_to_zig(expected);
1068                let _ = writeln!(
1069                    out,
1070                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1071                );
1072            }
1073        }
1074        "contains_all" => {
1075            if let Some(values) = &assertion.values {
1076                for val in values {
1077                    let zig_val = json_to_zig(val);
1078                    let _ = writeln!(
1079                        out,
1080                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1081                    );
1082                }
1083            }
1084        }
1085        "not_contains" => {
1086            if let Some(expected) = &assertion.value {
1087                let zig_val = json_to_zig(expected);
1088                let _ = writeln!(
1089                    out,
1090                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1091                );
1092            }
1093        }
1094        "not_empty" => {
1095            let _ = writeln!(out, "    try testing.expect({field_expr}.len > 0);");
1096        }
1097        "is_empty" => {
1098            let _ = writeln!(out, "    try testing.expect({field_expr}.len == 0);");
1099        }
1100        "starts_with" => {
1101            if let Some(expected) = &assertion.value {
1102                let zig_val = json_to_zig(expected);
1103                let _ = writeln!(
1104                    out,
1105                    "    try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
1106                );
1107            }
1108        }
1109        "ends_with" => {
1110            if let Some(expected) = &assertion.value {
1111                let zig_val = json_to_zig(expected);
1112                let _ = writeln!(
1113                    out,
1114                    "    try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
1115                );
1116            }
1117        }
1118        "min_length" => {
1119            if let Some(val) = &assertion.value {
1120                if let Some(n) = val.as_u64() {
1121                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
1122                }
1123            }
1124        }
1125        "max_length" => {
1126            if let Some(val) = &assertion.value {
1127                if let Some(n) = val.as_u64() {
1128                    let _ = writeln!(out, "    try testing.expect({field_expr}.len <= {n});");
1129                }
1130            }
1131        }
1132        "count_min" => {
1133            if let Some(val) = &assertion.value {
1134                if let Some(n) = val.as_u64() {
1135                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
1136                }
1137            }
1138        }
1139        "count_equals" => {
1140            if let Some(val) = &assertion.value {
1141                if let Some(n) = val.as_u64() {
1142                    // When there is no field (field_expr == result_var), the result
1143                    // is `[]u8` JSON (e.g. batch functions). Parse the JSON array
1144                    // and count its elements; `.len` would give byte count, not item count.
1145                    let has_field = assertion.field.as_deref().is_some_and(|f| !f.is_empty());
1146                    if has_field {
1147                        let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.len);");
1148                    } else {
1149                        let _ = writeln!(out, "    {{");
1150                        let _ = writeln!(
1151                            out,
1152                            "        var _cparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {field_expr}, .{{}});"
1153                        );
1154                        let _ = writeln!(out, "        defer _cparse.deinit();");
1155                        let _ = writeln!(
1156                            out,
1157                            "        try testing.expectEqual({n}, _cparse.value.array.items.len);"
1158                        );
1159                        let _ = writeln!(out, "    }}");
1160                    }
1161                }
1162            }
1163        }
1164        "is_true" => {
1165            let _ = writeln!(out, "    try testing.expect({field_expr});");
1166        }
1167        "is_false" => {
1168            let _ = writeln!(out, "    try testing.expect(!{field_expr});");
1169        }
1170        "not_error" => {
1171            // Already handled by the call succeeding.
1172        }
1173        "error" => {
1174            // Handled at the test function level.
1175        }
1176        "greater_than" => {
1177            if let Some(val) = &assertion.value {
1178                let zig_val = json_to_zig(val);
1179                let _ = writeln!(out, "    try testing.expect({field_expr} > {zig_val});");
1180            }
1181        }
1182        "less_than" => {
1183            if let Some(val) = &assertion.value {
1184                let zig_val = json_to_zig(val);
1185                let _ = writeln!(out, "    try testing.expect({field_expr} < {zig_val});");
1186            }
1187        }
1188        "greater_than_or_equal" => {
1189            if let Some(val) = &assertion.value {
1190                let zig_val = json_to_zig(val);
1191                let _ = writeln!(out, "    try testing.expect({field_expr} >= {zig_val});");
1192            }
1193        }
1194        "less_than_or_equal" => {
1195            if let Some(val) = &assertion.value {
1196                let zig_val = json_to_zig(val);
1197                let _ = writeln!(out, "    try testing.expect({field_expr} <= {zig_val});");
1198            }
1199        }
1200        "contains_any" => {
1201            // At least ONE of the values must be found in the field (OR logic).
1202            if let Some(values) = &assertion.values {
1203                let string_values: Vec<String> = values
1204                    .iter()
1205                    .filter_map(|v| {
1206                        if let serde_json::Value::String(s) = v {
1207                            Some(format!(
1208                                "std.mem.indexOf(u8, {field_expr}, \"{}\") != null",
1209                                escape_zig(s)
1210                            ))
1211                        } else {
1212                            None
1213                        }
1214                    })
1215                    .collect();
1216                if !string_values.is_empty() {
1217                    let condition = string_values.join(" or\n        ");
1218                    let _ = writeln!(out, "    try testing.expect(\n        {condition}\n    );");
1219                }
1220            }
1221        }
1222        "matches_regex" => {
1223            let _ = writeln!(out, "    // regex match not yet implemented for Zig");
1224        }
1225        "method_result" => {
1226            let _ = writeln!(out, "    // method_result assertions not yet implemented for Zig");
1227        }
1228        other => {
1229            panic!("Zig e2e generator: unsupported assertion type: {other}");
1230        }
1231    }
1232}
1233
1234/// Convert a `serde_json::Value` to a Zig literal string.
1235fn json_to_zig(value: &serde_json::Value) -> String {
1236    match value {
1237        serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
1238        serde_json::Value::Bool(b) => b.to_string(),
1239        serde_json::Value::Number(n) => n.to_string(),
1240        serde_json::Value::Null => "null".to_string(),
1241        serde_json::Value::Array(arr) => {
1242            let items: Vec<String> = arr.iter().map(json_to_zig).collect();
1243            format!("&.{{{}}}", items.join(", "))
1244        }
1245        serde_json::Value::Object(_) => {
1246            let json_str = serde_json::to_string(value).unwrap_or_default();
1247            format!("\"{}\"", escape_zig(&json_str))
1248        }
1249    }
1250}