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