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    let is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
551    // Client factory: when set, the test instantiates a client object via
552    // `module.factory_fn(...)` and calls methods on the instance rather than
553    // calling top-level package functions directly.
554    // Mirrors the go codegen pattern (go.rs:981-1028 / CallOverride.client_factory).
555    let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
556        e2e_config
557            .call
558            .overrides
559            .get(lang)
560            .and_then(|o| o.client_factory.as_deref())
561    });
562
563    // When `result_is_json_struct = true`, the Zig function returns `[]u8` JSON.
564    // The test parses it with `std.json.parseFromSlice(std.json.Value, ...)` and
565    // traverses the dynamic JSON object for field assertions.
566    //
567    // Client-factory methods on opaque handles always return JSON `[]u8` because
568    // the zig backend serializes struct results via the FFI's `*_to_json` helper
569    // (see alef-backend-zig/src/gen_bindings/opaque_handles.rs). Force the flag
570    // on whenever a client_factory is in play so the test path parses the JSON
571    // result rather than attempting direct field access on `[]u8`.
572    let result_is_json_struct = call_overrides.is_some_and(|o| o.result_is_json_struct) || client_factory.is_some();
573
574    let test_name = fixture.id.to_snake_case();
575    let description = &fixture.description;
576    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
577
578    let (setup_lines, args_str, setup_needs_gpa) = build_args_and_setup(&fixture.input, args, &fixture.id, module_name);
579
580    // Pre-compute whether any assertion will emit code that references `result` /
581    // `allocator`. Used to decide whether to emit the GPA allocator binding.
582    let any_happy_emits_code = fixture
583        .assertions
584        .iter()
585        .any(|a| assertion_emits_code(a, field_resolver));
586    let any_non_error_emits_code = fixture
587        .assertions
588        .iter()
589        .filter(|a| a.assertion_type != "error")
590        .any(|a| assertion_emits_code(a, field_resolver));
591
592    let _ = writeln!(out, "test \"{test_name}\" {{");
593    let _ = writeln!(out, "    // {description}");
594
595    // Emit GPA allocator only when it will actually be used: setup lines that
596    // need GPA allocation (mock_url), or a JSON-struct result path where the test
597    // will call `std.json.parseFromSlice`. The binding is not needed for
598    // error-only paths or tests with no field assertions.
599    // Note: `bytes` arg setup uses c_allocator directly and does NOT require GPA.
600    let needs_gpa = setup_needs_gpa
601        || (result_is_json_struct && !expects_error && any_happy_emits_code)
602        || (result_is_json_struct && expects_error && any_non_error_emits_code);
603    if needs_gpa {
604        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
605        let _ = writeln!(out, "    defer _ = gpa.deinit();");
606        let _ = writeln!(out, "    const allocator = gpa.allocator();");
607        let _ = writeln!(out);
608    }
609
610    for line in &setup_lines {
611        let _ = writeln!(out, "    {line}");
612    }
613
614    // Client factory: when configured, instantiate a client object via the named
615    // constructor function and call the method on the instance.
616    // The client is pointed at MOCK_SERVER_URL/fixtures/<id> (mirrors go.rs:981-1028).
617    // When not configured, fall back to calling the top-level package function directly.
618    let call_prefix = if let Some(factory) = client_factory {
619        let fixture_id = &fixture.id;
620        let _ = writeln!(
621            out,
622            "    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);"
623        );
624        let _ = writeln!(out, "    defer std.heap.c_allocator.free(_mock_url);");
625        let _ = writeln!(
626            out,
627            "    var _client = try {module_name}.{factory}(\"test-key\", _mock_url, null, null, null);"
628        );
629        let _ = writeln!(out, "    defer _client.free();");
630        "_client".to_string()
631    } else {
632        module_name.to_string()
633    };
634
635    if expects_error {
636        // Error-path test: use error union syntax `!T` and try-catch.
637        if is_async {
638            let _ = writeln!(
639                out,
640                "    // Note: async functions not yet fully supported; treating as sync"
641            );
642        }
643        if result_is_json_struct {
644            let _ = writeln!(
645                out,
646                "    const _result_json = {call_prefix}.{function_name}({args_str}) catch {{"
647            );
648        } else {
649            let _ = writeln!(
650                out,
651                "    const result = {call_prefix}.{function_name}({args_str}) catch {{"
652            );
653        }
654        let _ = writeln!(out, "        try testing.expect(true); // Error occurred as expected");
655        let _ = writeln!(out, "        return;");
656        let _ = writeln!(out, "    }};");
657        // Whether any non-error assertion will emit code that references `result`.
658        // If not, we must explicitly discard `result` to satisfy Zig's
659        // strict-unused-locals rule.
660        let any_emits_code = fixture
661            .assertions
662            .iter()
663            .filter(|a| a.assertion_type != "error")
664            .any(|a| assertion_emits_code(a, field_resolver));
665        if result_is_json_struct && any_emits_code {
666            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
667            let _ = writeln!(
668                out,
669                "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
670            );
671            let _ = writeln!(out, "    defer _parsed.deinit();");
672            let _ = writeln!(out, "    const {result_var} = &_parsed.value;");
673            let _ = writeln!(out, "    // Perform success assertions if any");
674            for assertion in &fixture.assertions {
675                if assertion.assertion_type != "error" {
676                    render_json_assertion(out, assertion, result_var, field_resolver);
677                }
678            }
679        } else if result_is_json_struct {
680            let _ = writeln!(out, "    _ = _result_json;");
681        } else if any_emits_code {
682            let _ = writeln!(out, "    // Perform success assertions if any");
683            for assertion in &fixture.assertions {
684                if assertion.assertion_type != "error" {
685                    render_assertion(out, assertion, result_var, field_resolver, enum_fields);
686                }
687            }
688        } else {
689            let _ = writeln!(out, "    _ = result;");
690        }
691    } else if fixture.assertions.is_empty() {
692        // No assertions: emit a call to verify compilation.
693        if is_async {
694            let _ = writeln!(
695                out,
696                "    // Note: async functions not yet fully supported; treating as sync"
697            );
698        }
699        if result_is_json_struct {
700            let _ = writeln!(
701                out,
702                "    const _result_json = try {call_prefix}.{function_name}({args_str});"
703            );
704            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
705        } else {
706            let _ = writeln!(out, "    _ = try {call_prefix}.{function_name}({args_str});");
707        }
708    } else {
709        // Happy path: call and assert. Detect whether any assertion actually
710        // emits code that references `result` (some — like `not_error` — emit
711        // nothing) so we don't leave an unused local, which Zig 0.16 rejects.
712        if is_async {
713            let _ = writeln!(
714                out,
715                "    // Note: async functions not yet fully supported; treating as sync"
716            );
717        }
718        let any_emits_code = fixture
719            .assertions
720            .iter()
721            .any(|a| assertion_emits_code(a, field_resolver));
722        if result_is_json_struct {
723            // JSON struct path: parse result JSON and access fields dynamically.
724            let _ = writeln!(
725                out,
726                "    const _result_json = try {call_prefix}.{function_name}({args_str});"
727            );
728            let _ = writeln!(out, "    defer std.heap.c_allocator.free(_result_json);");
729            if any_emits_code {
730                let _ = writeln!(
731                    out,
732                    "    var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
733                );
734                let _ = writeln!(out, "    defer _parsed.deinit();");
735                let _ = writeln!(out, "    const {result_var} = &_parsed.value;");
736                for assertion in &fixture.assertions {
737                    render_json_assertion(out, assertion, result_var, field_resolver);
738                }
739            }
740        } else if any_emits_code {
741            let _ = writeln!(
742                out,
743                "    const {result_var} = try {call_prefix}.{function_name}({args_str});"
744            );
745            for assertion in &fixture.assertions {
746                render_assertion(out, assertion, result_var, field_resolver, enum_fields);
747            }
748        } else {
749            let _ = writeln!(out, "    _ = try {call_prefix}.{function_name}({args_str});");
750        }
751    }
752
753    let _ = writeln!(out, "}}");
754}
755
756// ---------------------------------------------------------------------------
757// JSON-struct assertion rendering (for result_is_json_struct = true)
758// ---------------------------------------------------------------------------
759
760/// Convert a dot-separated field path into a chain of `std.json.Value` lookups.
761///
762/// Each segment uses `.object.get("key").?` to traverse the JSON object tree.
763/// The final segment stops before the leaf-type accessor so callers can append
764/// the appropriate accessor (`.string`, `.integer`, `.array.items`, etc.).
765///
766/// Returns `(base_expr, last_key)` where `base_expr` already includes all
767/// intermediate `.object.get("…").?` dereferences up to (but not including)
768/// the leaf, and `last_key` is the last path segment.
769/// Variant names of `FormatMetadata` (snake_case, from `#[serde(rename_all = "snake_case")]`).
770/// These appear as typed accessors in fixture paths (e.g. `format.excel.sheet_count`)
771/// but are NOT JSON keys — `FormatMetadata` is internally tagged so variant fields are
772/// flattened directly into the `format` object alongside the `format_type` discriminant.
773const FORMAT_METADATA_VARIANTS: &[&str] = &[
774    "pdf",
775    "docx",
776    "excel",
777    "email",
778    "pptx",
779    "archive",
780    "image",
781    "xml",
782    "text",
783    "html",
784    "ocr",
785    "csv",
786    "bibtex",
787    "citation",
788    "fiction_book",
789    "dbf",
790    "jats",
791    "epub",
792    "pst",
793    "code",
794];
795
796fn json_path_expr(result_var: &str, field_path: &str) -> String {
797    let segments: Vec<&str> = field_path.split('.').collect();
798    let mut expr = result_var.to_string();
799    let mut prev_seg: Option<&str> = None;
800    for seg in &segments {
801        // Skip variant-name accessor segments that follow a `format` key.
802        // FormatMetadata is an internally-tagged enum (`#[serde(tag = "format_type")]`),
803        // so variant fields are flattened directly into the format object — there is no
804        // intermediate JSON key for the variant name.
805        if prev_seg == Some("format") && FORMAT_METADATA_VARIANTS.contains(seg) {
806            prev_seg = Some(seg);
807            continue;
808        }
809        // Handle array accessor notation: "links[]" → access the array, then first element.
810        if let Some(key) = seg.strip_suffix("[]") {
811            expr = format!("{expr}.object.get(\"{key}\").?.array.items[0]");
812        } else {
813            expr = format!("{expr}.object.get(\"{seg}\").?");
814        }
815        prev_seg = Some(seg);
816    }
817    expr
818}
819
820/// Render a single assertion for a JSON-struct result (result_is_json_struct = true).
821///
822/// The `result_var` variable is `*std.json.Value` (pointer to the parsed root object).
823/// Field paths are traversed via `.object.get("key").?` chains.
824fn render_json_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
825    // Skip assertions on fields that don't exist on the result type.
826    if let Some(f) = &assertion.field {
827        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
828            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
829            return;
830        }
831    }
832    // error/not_error are handled at the call level, not assertion level.
833    if matches!(assertion.assertion_type.as_str(), "not_error" | "error") {
834        return;
835    }
836
837    let raw_field_path = assertion.field.as_deref().unwrap_or("").trim();
838    let field_path = if raw_field_path.is_empty() {
839        raw_field_path.to_string()
840    } else {
841        field_resolver.resolve(raw_field_path).to_string()
842    };
843    let field_path = field_path.trim();
844
845    // "{array_field}.length" → strip suffix; use .array.items.len in the template.
846    let (field_path_for_expr, is_length_access) = if let Some(parent) = field_path.strip_suffix(".length") {
847        (parent, true)
848    } else {
849        (field_path, false)
850    };
851
852    let field_expr = if field_path_for_expr.is_empty() {
853        result_var.to_string()
854    } else {
855        json_path_expr(result_var, field_path_for_expr)
856    };
857
858    // Compute context variables for the template.
859    let zig_val = match &assertion.value {
860        Some(serde_json::Value::String(s)) => format!("\"{}\"", escape_zig(s)),
861        _ => String::new(),
862    };
863    let is_string_val = matches!(&assertion.value, Some(serde_json::Value::String(_)));
864    let is_bool_val = matches!(&assertion.value, Some(serde_json::Value::Bool(_)));
865    let bool_val = match &assertion.value {
866        Some(serde_json::Value::Bool(b)) if *b => "true",
867        _ => "false",
868    };
869    let is_null_val = matches!(&assertion.value, Some(serde_json::Value::Null));
870    let n = assertion.value.as_ref().map(json_to_zig).unwrap_or_default();
871    let has_n = assertion.value.as_ref().is_some_and(|v| v.is_number() || v.is_u64());
872    let values_list: Vec<String> = assertion
873        .values
874        .as_deref()
875        .unwrap_or_default()
876        .iter()
877        .filter_map(|v| {
878            if let serde_json::Value::String(s) = v {
879                Some(format!("\"{}\"", escape_zig(s)))
880            } else {
881                None
882            }
883        })
884        .collect();
885
886    let rendered = crate::template_env::render(
887        "zig/json_assertion.jinja",
888        minijinja::context! {
889            assertion_type => assertion.assertion_type.as_str(),
890            field_expr => field_expr,
891            is_length_access => is_length_access,
892            zig_val => zig_val,
893            is_string_val => is_string_val,
894            is_bool_val => is_bool_val,
895            bool_val => bool_val,
896            is_null_val => is_null_val,
897            n => n,
898            has_n => has_n,
899            values_list => values_list,
900        },
901    );
902    out.push_str(&rendered);
903}
904
905/// Predicate matching `render_assertion`: returns true when the assertion
906/// would emit at least one statement that references the result variable.
907fn assertion_emits_code(assertion: &Assertion, field_resolver: &FieldResolver) -> bool {
908    if let Some(f) = &assertion.field {
909        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
910            return false;
911        }
912    }
913    matches!(
914        assertion.assertion_type.as_str(),
915        "equals"
916            | "contains"
917            | "contains_all"
918            | "not_contains"
919            | "not_empty"
920            | "is_empty"
921            | "starts_with"
922            | "ends_with"
923            | "min_length"
924            | "max_length"
925            | "count_min"
926            | "count_equals"
927            | "is_true"
928            | "is_false"
929            | "greater_than"
930            | "less_than"
931            | "greater_than_or_equal"
932            | "less_than_or_equal"
933            | "contains_any"
934    )
935}
936
937/// Build setup lines and the argument list for the function call.
938///
939/// Returns `(setup_lines, args_str, setup_needs_gpa)` where `setup_needs_gpa`
940/// is `true` when at least one setup line requires the GPA `allocator` binding.
941fn build_args_and_setup(
942    input: &serde_json::Value,
943    args: &[crate::config::ArgMapping],
944    fixture_id: &str,
945    _module_name: &str,
946) -> (Vec<String>, String, bool) {
947    if args.is_empty() {
948        return (Vec::new(), String::new(), false);
949    }
950
951    let mut setup_lines: Vec<String> = Vec::new();
952    let mut parts: Vec<String> = Vec::new();
953    let mut setup_needs_gpa = false;
954
955    for arg in args {
956        if arg.arg_type == "mock_url" {
957            let name = arg.name.clone();
958            let id_upper = fixture_id.to_uppercase();
959            setup_lines.push(format!(
960                "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\"}});"
961            ));
962            setup_lines.push(format!("defer allocator.free({name});"));
963            parts.push(name);
964            setup_needs_gpa = true;
965            continue;
966        }
967
968        // Handle args (engine handle): serialize config to JSON string literal, or null.
969        // The Zig binding accepts ?[]const u8 for engine params (creates handle internally).
970        if arg.arg_type == "handle" {
971            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
972            let json_str = match input.get(field) {
973                Some(serde_json::Value::Null) | None => "null".to_string(),
974                Some(v) => format!("\"{}\"", escape_zig(&serde_json::to_string(v).unwrap_or_default())),
975            };
976            parts.push(json_str);
977            continue;
978        }
979
980        // The Zig wrapper accepts struct parameters (e.g. `ExtractionConfig`)
981        // as JSON `[]const u8`, converting them to opaque FFI handles via the
982        // `<prefix>_<snake>_from_json` helper at the binding layer. Emit the
983        // fixture's configuration value as a JSON string literal, falling back
984        // to `"{}"` when the fixture omits a config so callers exercise the
985        // default path.
986        if arg.name == "config" && arg.arg_type == "json_object" {
987            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
988            let json_str = match input.get(field) {
989                Some(serde_json::Value::Null) | None => "{}".to_string(),
990                Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
991            };
992            parts.push(format!("\"{}\"", escape_zig(&json_str)));
993            continue;
994        }
995
996        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
997        let val = input.get(field);
998        match val {
999            None | Some(serde_json::Value::Null) if arg.optional => {
1000                // Zig functions don't have default arguments, so we must
1001                // pass `null` explicitly for every optional parameter.
1002                parts.push("null".to_string());
1003            }
1004            None | Some(serde_json::Value::Null) => {
1005                let default_val = match arg.arg_type.as_str() {
1006                    "string" => "\"\"".to_string(),
1007                    "int" | "integer" => "0".to_string(),
1008                    "float" | "number" => "0.0".to_string(),
1009                    "bool" | "boolean" => "false".to_string(),
1010                    "json_object" => "\"{}\"".to_string(),
1011                    _ => "null".to_string(),
1012                };
1013                parts.push(default_val);
1014            }
1015            Some(v) => {
1016                // For `json_object` arguments other than `config` (handled
1017                // above) the Zig binding accepts a JSON `[]const u8`, so we
1018                // serialize the entire fixture value as a single JSON string
1019                // literal rather than rendering it as a Zig array/struct.
1020                if arg.arg_type == "json_object" {
1021                    let json_str = serde_json::to_string(v).unwrap_or_default();
1022                    parts.push(format!("\"{}\"", escape_zig(&json_str)));
1023                } else if arg.arg_type == "bytes" {
1024                    // `bytes` args are file paths in fixtures — read the file into a
1025                    // local buffer. The cwd is set to test_documents/ at runtime.
1026                    // Zig 0.16 uses std.Io.Dir.cwd() (not std.fs.cwd()) and requires
1027                    // an `io` instance from std.testing.io in test context.
1028                    if let serde_json::Value::String(path) = v {
1029                        let var_name = format!("{}_bytes", arg.name);
1030                        let epath = escape_zig(path);
1031                        setup_lines.push(format!(
1032                            "const {var_name} = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, \"{epath}\", std.heap.c_allocator, .unlimited);"
1033                        ));
1034                        setup_lines.push(format!("defer std.heap.c_allocator.free({var_name});"));
1035                        parts.push(var_name);
1036                    } else {
1037                        parts.push(json_to_zig(v));
1038                    }
1039                } else {
1040                    parts.push(json_to_zig(v));
1041                }
1042            }
1043        }
1044    }
1045
1046    (setup_lines, parts.join(", "), setup_needs_gpa)
1047}
1048
1049fn render_assertion(
1050    out: &mut String,
1051    assertion: &Assertion,
1052    result_var: &str,
1053    field_resolver: &FieldResolver,
1054    enum_fields: &HashSet<String>,
1055) {
1056    // Skip assertions on fields that don't exist on the result type.
1057    if let Some(f) = &assertion.field {
1058        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1059            let _ = writeln!(out, "    // skipped: field '{{f}}' not available on result type");
1060            return;
1061        }
1062    }
1063
1064    // Determine if this field is an enum type.
1065    let _field_is_enum = assertion
1066        .field
1067        .as_deref()
1068        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1069
1070    let field_expr = match &assertion.field {
1071        Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
1072        _ => result_var.to_string(),
1073    };
1074
1075    match assertion.assertion_type.as_str() {
1076        "equals" => {
1077            if let Some(expected) = &assertion.value {
1078                let zig_val = json_to_zig(expected);
1079                let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {field_expr});");
1080            }
1081        }
1082        "contains" => {
1083            if let Some(expected) = &assertion.value {
1084                let zig_val = json_to_zig(expected);
1085                let _ = writeln!(
1086                    out,
1087                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1088                );
1089            }
1090        }
1091        "contains_all" => {
1092            if let Some(values) = &assertion.values {
1093                for val in values {
1094                    let zig_val = json_to_zig(val);
1095                    let _ = writeln!(
1096                        out,
1097                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1098                    );
1099                }
1100            }
1101        }
1102        "not_contains" => {
1103            if let Some(expected) = &assertion.value {
1104                let zig_val = json_to_zig(expected);
1105                let _ = writeln!(
1106                    out,
1107                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1108                );
1109            }
1110        }
1111        "not_empty" => {
1112            let _ = writeln!(out, "    try testing.expect({field_expr}.len > 0);");
1113        }
1114        "is_empty" => {
1115            let _ = writeln!(out, "    try testing.expect({field_expr}.len == 0);");
1116        }
1117        "starts_with" => {
1118            if let Some(expected) = &assertion.value {
1119                let zig_val = json_to_zig(expected);
1120                let _ = writeln!(
1121                    out,
1122                    "    try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
1123                );
1124            }
1125        }
1126        "ends_with" => {
1127            if let Some(expected) = &assertion.value {
1128                let zig_val = json_to_zig(expected);
1129                let _ = writeln!(
1130                    out,
1131                    "    try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
1132                );
1133            }
1134        }
1135        "min_length" => {
1136            if let Some(val) = &assertion.value {
1137                if let Some(n) = val.as_u64() {
1138                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
1139                }
1140            }
1141        }
1142        "max_length" => {
1143            if let Some(val) = &assertion.value {
1144                if let Some(n) = val.as_u64() {
1145                    let _ = writeln!(out, "    try testing.expect({field_expr}.len <= {n});");
1146                }
1147            }
1148        }
1149        "count_min" => {
1150            if let Some(val) = &assertion.value {
1151                if let Some(n) = val.as_u64() {
1152                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
1153                }
1154            }
1155        }
1156        "count_equals" => {
1157            if let Some(val) = &assertion.value {
1158                if let Some(n) = val.as_u64() {
1159                    // When there is no field (field_expr == result_var), the result
1160                    // is `[]u8` JSON (e.g. batch functions). Parse the JSON array
1161                    // and count its elements; `.len` would give byte count, not item count.
1162                    let has_field = assertion.field.as_deref().is_some_and(|f| !f.is_empty());
1163                    if has_field {
1164                        let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.len);");
1165                    } else {
1166                        let _ = writeln!(out, "    {{");
1167                        let _ = writeln!(
1168                            out,
1169                            "        var _cparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {field_expr}, .{{}});"
1170                        );
1171                        let _ = writeln!(out, "        defer _cparse.deinit();");
1172                        let _ = writeln!(
1173                            out,
1174                            "        try testing.expectEqual({n}, _cparse.value.array.items.len);"
1175                        );
1176                        let _ = writeln!(out, "    }}");
1177                    }
1178                }
1179            }
1180        }
1181        "is_true" => {
1182            let _ = writeln!(out, "    try testing.expect({field_expr});");
1183        }
1184        "is_false" => {
1185            let _ = writeln!(out, "    try testing.expect(!{field_expr});");
1186        }
1187        "not_error" => {
1188            // Already handled by the call succeeding.
1189        }
1190        "error" => {
1191            // Handled at the test function level.
1192        }
1193        "greater_than" => {
1194            if let Some(val) = &assertion.value {
1195                let zig_val = json_to_zig(val);
1196                let _ = writeln!(out, "    try testing.expect({field_expr} > {zig_val});");
1197            }
1198        }
1199        "less_than" => {
1200            if let Some(val) = &assertion.value {
1201                let zig_val = json_to_zig(val);
1202                let _ = writeln!(out, "    try testing.expect({field_expr} < {zig_val});");
1203            }
1204        }
1205        "greater_than_or_equal" => {
1206            if let Some(val) = &assertion.value {
1207                let zig_val = json_to_zig(val);
1208                let _ = writeln!(out, "    try testing.expect({field_expr} >= {zig_val});");
1209            }
1210        }
1211        "less_than_or_equal" => {
1212            if let Some(val) = &assertion.value {
1213                let zig_val = json_to_zig(val);
1214                let _ = writeln!(out, "    try testing.expect({field_expr} <= {zig_val});");
1215            }
1216        }
1217        "contains_any" => {
1218            // At least ONE of the values must be found in the field (OR logic).
1219            if let Some(values) = &assertion.values {
1220                let string_values: Vec<String> = values
1221                    .iter()
1222                    .filter_map(|v| {
1223                        if let serde_json::Value::String(s) = v {
1224                            Some(format!(
1225                                "std.mem.indexOf(u8, {field_expr}, \"{}\") != null",
1226                                escape_zig(s)
1227                            ))
1228                        } else {
1229                            None
1230                        }
1231                    })
1232                    .collect();
1233                if !string_values.is_empty() {
1234                    let condition = string_values.join(" or\n        ");
1235                    let _ = writeln!(out, "    try testing.expect(\n        {condition}\n    );");
1236                }
1237            }
1238        }
1239        "matches_regex" => {
1240            let _ = writeln!(out, "    // regex match not yet implemented for Zig");
1241        }
1242        "method_result" => {
1243            let _ = writeln!(out, "    // method_result assertions not yet implemented for Zig");
1244        }
1245        other => {
1246            panic!("Zig e2e generator: unsupported assertion type: {other}");
1247        }
1248    }
1249}
1250
1251/// Convert a `serde_json::Value` to a Zig literal string.
1252fn json_to_zig(value: &serde_json::Value) -> String {
1253    match value {
1254        serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
1255        serde_json::Value::Bool(b) => b.to_string(),
1256        serde_json::Value::Number(n) => n.to_string(),
1257        serde_json::Value::Null => "null".to_string(),
1258        serde_json::Value::Array(arr) => {
1259            let items: Vec<String> = arr.iter().map(json_to_zig).collect();
1260            format!("&.{{{}}}", items.join(", "))
1261        }
1262        serde_json::Value::Object(_) => {
1263            let json_str = serde_json::to_string(value).unwrap_or_default();
1264            format!("\"{}\"", escape_zig(&json_str))
1265        }
1266    }
1267}