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    ) -> Result<Vec<GeneratedFile>> {
33        let lang = self.language_name();
34        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36        let mut files = Vec::new();
37
38        // Resolve call config with overrides.
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get(lang);
41        let _module_path = overrides
42            .and_then(|o| o.module.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.module.clone());
45        let function_name = overrides
46            .and_then(|o| o.function.as_ref())
47            .cloned()
48            .unwrap_or_else(|| call.function.clone());
49        let result_var = &call.result_var;
50
51        // Resolve package config.
52        let zig_pkg = e2e_config.resolve_package("zig");
53        let pkg_path = zig_pkg
54            .as_ref()
55            .and_then(|p| p.path.as_ref())
56            .cloned()
57            .unwrap_or_else(|| "../../packages/zig".to_string());
58        let pkg_name = zig_pkg
59            .as_ref()
60            .and_then(|p| p.name.as_ref())
61            .cloned()
62            .unwrap_or_else(|| config.name.to_snake_case());
63
64        // Generate build.zig.zon (Zig package manifest).
65        files.push(GeneratedFile {
66            path: output_base.join("build.zig.zon"),
67            content: render_build_zig_zon(&pkg_name, &pkg_path, e2e_config.dep_mode),
68            generated_header: false,
69        });
70
71        // Get the module name for imports.
72        let module_name = config.zig_module_name();
73
74        // Generate build.zig - collect test file names first.
75        let field_resolver = FieldResolver::new(
76            &e2e_config.fields,
77            &e2e_config.fields_optional,
78            &e2e_config.result_fields,
79            &e2e_config.fields_array,
80            &e2e_config.fields_method_calls,
81        );
82
83        // Generate test files per category and collect their names.
84        let mut test_filenames: Vec<String> = Vec::new();
85        for group in groups {
86            let active: Vec<&Fixture> = group
87                .fixtures
88                .iter()
89                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
90                .collect();
91
92            if active.is_empty() {
93                continue;
94            }
95
96            let filename = format!("{}_test.zig", sanitize_filename(&group.category));
97            test_filenames.push(filename.clone());
98            let content = render_test_file(
99                &group.category,
100                &active,
101                e2e_config,
102                &function_name,
103                result_var,
104                &e2e_config.call.args,
105                &field_resolver,
106                &e2e_config.fields_enum,
107                &module_name,
108            );
109            files.push(GeneratedFile {
110                path: output_base.join("src").join(filename),
111                content,
112                generated_header: true,
113            });
114        }
115
116        // Generate build.zig with collected test files.
117        files.insert(
118            files
119                .iter()
120                .position(|f| f.path.file_name().is_some_and(|n| n == "build.zig.zon"))
121                .unwrap_or(1),
122            GeneratedFile {
123                path: output_base.join("build.zig"),
124                content: render_build_zig(&test_filenames),
125                generated_header: false,
126            },
127        );
128
129        Ok(files)
130    }
131
132    fn language_name(&self) -> &'static str {
133        "zig"
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Rendering
139// ---------------------------------------------------------------------------
140
141fn render_build_zig_zon(pkg_name: &str, pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
142    let dep_block = match dep_mode {
143        crate::config::DependencyMode::Registry => {
144            // For registry mode, use a dummy hash (in real Zig, hash must be computed).
145            format!(
146                r#".{{
147            .url = "https://registry.example.com/{pkg_name}/v0.1.0.tar.gz",
148            .hash = "0000000000000000000000000000000000000000000000000000000000000000",
149        }}"#
150            )
151        }
152        crate::config::DependencyMode::Local => {
153            format!(r#".{{ .path = "{pkg_path}" }}"#)
154        }
155    };
156
157    let min_zig = toolchain::MIN_ZIG_VERSION;
158    // Zig 0.16+ requires a fingerprint of the form (crc32_ieee(name) << 32) | id.
159    let name_bytes: &[u8] = b"e2e_zig";
160    let mut crc: u32 = 0xffff_ffff;
161    for byte in name_bytes {
162        crc ^= *byte as u32;
163        for _ in 0..8 {
164            let mask = (crc & 1).wrapping_neg();
165            crc = (crc >> 1) ^ (0xedb8_8320 & mask);
166        }
167    }
168    let name_crc: u32 = !crc;
169    let mut id: u32 = 0x811c_9dc5;
170    for byte in name_bytes {
171        id ^= *byte as u32;
172        id = id.wrapping_mul(0x0100_0193);
173    }
174    if id == 0 || id == 0xffff_ffff {
175        id = 0x1;
176    }
177    let fingerprint: u64 = ((name_crc as u64) << 32) | (id as u64);
178    format!(
179        r#".{{
180    .name = .e2e_zig,
181    .version = "0.1.0",
182    .fingerprint = 0x{fingerprint:016x},
183    .minimum_zig_version = "{min_zig}",
184    .dependencies = .{{
185        .{pkg_name} = {dep_block},
186    }},
187    .paths = .{{
188        "build.zig",
189        "build.zig.zon",
190        "src",
191    }},
192}}
193"#
194    )
195}
196
197fn render_build_zig(test_filenames: &[String]) -> String {
198    if test_filenames.is_empty() {
199        return r#"const std = @import("std");
200
201pub fn build(b: *std.Build) void {
202    const target = b.standardTargetOptions(.{});
203    const optimize = b.standardOptimizeOption(.{});
204
205    const test_step = b.step("test", "Run tests");
206}
207"#
208        .to_string();
209    }
210
211    let mut content = String::from("const std = @import(\"std\");\n\npub fn build(b: *std.Build) void {\n");
212    content.push_str("    const target = b.standardTargetOptions(.{});\n");
213    content.push_str("    const optimize = b.standardOptimizeOption(.{});\n");
214    content.push_str("    const test_step = b.step(\"test\", \"Run tests\");\n");
215    content.push_str("    const ffi_path = b.option([]const u8, \"ffi_path\", \"Path to directory containing libkreuzberg_ffi\") orelse \"../../target/debug\";\n");
216    content.push_str("    const ffi_include = b.option([]const u8, \"ffi_include_path\", \"Path to directory containing kreuzberg FFI header\") orelse \"../../crates/kreuzberg-ffi/include\";\n\n");
217    content.push_str("    const kreuzberg_module = b.addModule(\"kreuzberg\", .{\n");
218    content.push_str("        .root_source_file = b.path(\"../../packages/zig/src/kreuzberg.zig\"),\n");
219    content.push_str("        .target = target,\n");
220    content.push_str("        .optimize = optimize,\n");
221    content.push_str("    });\n");
222    content.push_str("    kreuzberg_module.addLibraryPath(.{ .cwd_relative = ffi_path });\n");
223    content.push_str("    kreuzberg_module.addIncludePath(.{ .cwd_relative = ffi_include });\n");
224    content.push_str("    kreuzberg_module.linkSystemLibrary(\"kreuzberg_ffi\", .{});\n\n");
225
226    for filename in test_filenames {
227        // Convert filename like "basic_test.zig" to a test name
228        let test_name = filename.trim_end_matches("_test.zig");
229        content.push_str(&format!("    const {test_name}_module = b.createModule(.{{\n"));
230        content.push_str(&format!("        .root_source_file = b.path(\"src/{filename}\"),\n"));
231        content.push_str("        .target = target,\n");
232        content.push_str("        .optimize = optimize,\n");
233        content.push_str("    });\n");
234        content.push_str(&format!(
235            "    {test_name}_module.addImport(\"kreuzberg\", kreuzberg_module);\n"
236        ));
237        content.push_str(&format!("    const {test_name}_tests = b.addTest(.{{\n"));
238        content.push_str(&format!("        .root_module = {test_name}_module,\n"));
239        content.push_str("    });\n");
240        content.push_str(&format!(
241            "    const {test_name}_run = b.addRunArtifact({test_name}_tests);\n"
242        ));
243        content.push_str(&format!("    test_step.dependOn(&{test_name}_run.step);\n\n"));
244    }
245
246    content.push_str("}\n");
247    content
248}
249
250// ---------------------------------------------------------------------------
251// HTTP server test rendering — shared-driver integration
252// ---------------------------------------------------------------------------
253
254/// Renderer that emits Zig `test "..." { ... }` blocks targeting a mock server
255/// via `std.http.Client`. Satisfies [`client::TestClientRenderer`] so the shared
256/// [`client::http_call::render_http_test`] driver drives the call sequence.
257struct ZigTestClientRenderer;
258
259impl client::TestClientRenderer for ZigTestClientRenderer {
260    fn language_name(&self) -> &'static str {
261        "zig"
262    }
263
264    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
265        if let Some(reason) = skip_reason {
266            let _ = writeln!(out, "test \"{fn_name}\" {{");
267            let _ = writeln!(out, "    // {description}");
268            let _ = writeln!(out, "    // skipped: {reason}");
269            let _ = writeln!(out, "    return error.SkipZigTest;");
270        } else {
271            let _ = writeln!(out, "test \"{fn_name}\" {{");
272            let _ = writeln!(out, "    // {description}");
273        }
274    }
275
276    fn render_test_close(&self, out: &mut String) {
277        let _ = writeln!(out, "}}");
278    }
279
280    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
281        let method = ctx.method.to_uppercase();
282        let fixture_id = ctx.path.trim_start_matches("/fixtures/");
283
284        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
285        let _ = writeln!(out, "    defer _ = gpa.deinit();");
286        let _ = writeln!(out, "    const allocator = gpa.allocator();");
287
288        let _ = writeln!(out, "    var url_buf: [512]u8 = undefined;");
289        let _ = writeln!(
290            out,
291            "    const url = try std.fmt.bufPrint(&url_buf, \"{{s}}/fixtures/{fixture_id}\", .{{std.posix.getenv(\"MOCK_SERVER_URL\") orelse \"http://localhost:8080\"}});"
292        );
293
294        // Headers
295        if !ctx.headers.is_empty() {
296            let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
297            header_pairs.sort_by_key(|(k, _)| k.as_str());
298            let _ = writeln!(out, "    const headers = [_]std.http.Header{{");
299            for (k, v) in &header_pairs {
300                let ek = escape_zig(k);
301                let ev = escape_zig(v);
302                let _ = writeln!(out, "        .{{ .name = \"{ek}\", .value = \"{ev}\" }},");
303            }
304            let _ = writeln!(out, "    }};");
305        }
306
307        // Body
308        if let Some(body) = ctx.body {
309            let json_str = serde_json::to_string(body).unwrap_or_default();
310            let escaped = escape_zig(&json_str);
311            let _ = writeln!(out, "    const body_bytes: []const u8 = \"{escaped}\";");
312        }
313
314        let headers_arg = if ctx.headers.is_empty() { "&.{}" } else { "&headers" };
315        let has_body = ctx.body.is_some();
316
317        let _ = writeln!(
318            out,
319            "    var http_client = std.http.Client{{ .allocator = allocator }};"
320        );
321        let _ = writeln!(out, "    defer http_client.deinit();");
322        let _ = writeln!(out, "    var response_body = std.ArrayList(u8).init(allocator);");
323        let _ = writeln!(out, "    defer response_body.deinit();");
324
325        let method_zig = match method.as_str() {
326            "GET" => ".GET",
327            "POST" => ".POST",
328            "PUT" => ".PUT",
329            "DELETE" => ".DELETE",
330            "PATCH" => ".PATCH",
331            "HEAD" => ".HEAD",
332            "OPTIONS" => ".OPTIONS",
333            _ => ".GET",
334        };
335
336        let payload_field = if has_body { ", .payload = body_bytes" } else { "" };
337        let _ = writeln!(
338            out,
339            "    const {rv} = try http_client.fetch(.{{ .location = .{{ .url = url }}, .method = {method_zig}, .extra_headers = {headers_arg}{payload_field}, .response_storage = .{{ .dynamic = &response_body }} }});",
340            rv = ctx.response_var,
341        );
342    }
343
344    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
345        let _ = writeln!(
346            out,
347            "    try testing.expectEqual(@as(u10, {status}), @intFromEnum({response_var}.status));"
348        );
349    }
350
351    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
352        let ename = escape_zig(&name.to_lowercase());
353        match expected {
354            "<<present>>" => {
355                let _ = writeln!(
356                    out,
357                    "    // assert header '{ename}' is present (header inspection not yet implemented)"
358                );
359            }
360            "<<absent>>" => {
361                let _ = writeln!(
362                    out,
363                    "    // assert header '{ename}' is absent (header inspection not yet implemented)"
364                );
365            }
366            "<<uuid>>" => {
367                let _ = writeln!(
368                    out,
369                    "    // assert header '{ename}' matches UUID pattern (header inspection not yet implemented)"
370                );
371            }
372            exact => {
373                let evalue = escape_zig(exact);
374                let _ = writeln!(
375                    out,
376                    "    // assert header '{ename}' == \"{evalue}\" (header inspection not yet implemented)"
377                );
378            }
379        }
380    }
381
382    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
383        let json_str = serde_json::to_string(expected).unwrap_or_default();
384        let escaped = escape_zig(&json_str);
385        let _ = writeln!(
386            out,
387            "    try testing.expectEqualStrings(\"{escaped}\", response_body.items);"
388        );
389    }
390
391    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
392        if let Some(obj) = expected.as_object() {
393            for (key, val) in obj {
394                let ekey = escape_zig(key);
395                let eval = escape_zig(&serde_json::to_string(val).unwrap_or_default());
396                let _ = writeln!(
397                    out,
398                    "    // assert body contains field \"{ekey}\" = \"{eval}\" (partial JSON not yet implemented)"
399                );
400            }
401        }
402    }
403
404    fn render_assert_validation_errors(
405        &self,
406        out: &mut String,
407        _response_var: &str,
408        errors: &[crate::fixture::ValidationErrorExpectation],
409    ) {
410        for ve in errors {
411            let loc = ve.loc.join(".");
412            let escaped_loc = escape_zig(&loc);
413            let escaped_msg = escape_zig(&ve.msg);
414            let _ = writeln!(
415                out,
416                "    // assert validation error at \"{escaped_loc}\": \"{escaped_msg}\" (not yet implemented)"
417            );
418        }
419    }
420}
421
422/// Render a Zig `test "..." { ... }` block for an HTTP server fixture.
423///
424/// Delegates to the shared [`client::http_call::render_http_test`] driver via
425/// [`ZigTestClientRenderer`].
426fn render_http_test_case(out: &mut String, fixture: &Fixture) {
427    client::http_call::render_http_test(out, &ZigTestClientRenderer, fixture);
428}
429
430// ---------------------------------------------------------------------------
431// Function-call test rendering
432// ---------------------------------------------------------------------------
433
434#[allow(clippy::too_many_arguments)]
435fn render_test_file(
436    category: &str,
437    fixtures: &[&Fixture],
438    e2e_config: &E2eConfig,
439    function_name: &str,
440    result_var: &str,
441    args: &[crate::config::ArgMapping],
442    field_resolver: &FieldResolver,
443    enum_fields: &HashSet<String>,
444    module_name: &str,
445) -> String {
446    let mut out = String::new();
447    out.push_str(&hash::header(CommentStyle::DoubleSlash));
448    let _ = writeln!(out, "const std = @import(\"std\");");
449    let _ = writeln!(out, "const testing = std.testing;");
450    let _ = writeln!(out, "const {module_name} = @import(\"{module_name}\");");
451    let _ = writeln!(out);
452
453    let _ = writeln!(out, "// E2e tests for category: {category}");
454    let _ = writeln!(out);
455
456    for fixture in fixtures {
457        if fixture.http.is_some() {
458            render_http_test_case(&mut out, fixture);
459        } else {
460            render_test_fn(
461                &mut out,
462                fixture,
463                e2e_config,
464                function_name,
465                result_var,
466                args,
467                field_resolver,
468                enum_fields,
469                module_name,
470            );
471        }
472        let _ = writeln!(out);
473    }
474
475    out
476}
477
478#[allow(clippy::too_many_arguments)]
479fn render_test_fn(
480    out: &mut String,
481    fixture: &Fixture,
482    e2e_config: &E2eConfig,
483    _function_name: &str,
484    _result_var: &str,
485    _args: &[crate::config::ArgMapping],
486    field_resolver: &FieldResolver,
487    enum_fields: &HashSet<String>,
488    module_name: &str,
489) {
490    // Resolve per-fixture call config.
491    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
492    let lang = "zig";
493    let call_overrides = call_config.overrides.get(lang);
494    let function_name = call_overrides
495        .and_then(|o| o.function.as_ref())
496        .cloned()
497        .unwrap_or_else(|| call_config.function.clone());
498    let result_var = &call_config.result_var;
499    let args = &call_config.args;
500    let is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
501
502    let test_name = fixture.id.to_snake_case();
503    let description = &fixture.description;
504    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
505
506    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id, module_name);
507
508    let _ = writeln!(out, "test \"{test_name}\" {{");
509    let _ = writeln!(out, "    // {description}");
510
511    // Only emit allocator setup when setup lines actually need it (avoids unused-variable errors).
512    let needs_alloc = !setup_lines.is_empty();
513    if needs_alloc {
514        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
515        let _ = writeln!(out, "    defer _ = gpa.deinit();");
516        let _ = writeln!(out, "    const allocator = gpa.allocator();");
517        let _ = writeln!(out);
518    }
519
520    for line in &setup_lines {
521        let _ = writeln!(out, "    {line}");
522    }
523
524    if expects_error {
525        // Error-path test: use error union syntax `!T` and try-catch.
526        if is_async {
527            let _ = writeln!(
528                out,
529                "    // Note: async functions not yet fully supported; treating as sync"
530            );
531        }
532        let _ = writeln!(
533            out,
534            "    const result = {module_name}.{function_name}({args_str}) catch {{"
535        );
536        let _ = writeln!(out, "        try testing.expect(true); // Error occurred as expected");
537        let _ = writeln!(out, "        return;");
538        let _ = writeln!(out, "    }};");
539        // Whether any non-error assertion will emit code that references `result`.
540        // If not, we must explicitly discard `result` to satisfy Zig's
541        // strict-unused-locals rule.
542        let any_emits_code = fixture
543            .assertions
544            .iter()
545            .filter(|a| a.assertion_type != "error")
546            .any(|a| assertion_emits_code(a, field_resolver));
547        if any_emits_code {
548            let _ = writeln!(out, "    // Perform success assertions if any");
549            for assertion in &fixture.assertions {
550                if assertion.assertion_type != "error" {
551                    render_assertion(out, assertion, result_var, field_resolver, enum_fields);
552                }
553            }
554        } else {
555            let _ = writeln!(out, "    _ = result;");
556        }
557    } else if fixture.assertions.is_empty() {
558        // No assertions: emit a call to verify compilation.
559        if is_async {
560            let _ = writeln!(
561                out,
562                "    // Note: async functions not yet fully supported; treating as sync"
563            );
564        }
565        let _ = writeln!(out, "    _ = try {module_name}.{function_name}({args_str});");
566    } else {
567        // Happy path: call and assert. Detect whether any assertion actually
568        // emits code that references `result` (some — like `not_error` — emit
569        // nothing) so we don't leave an unused local, which Zig 0.16 rejects.
570        if is_async {
571            let _ = writeln!(
572                out,
573                "    // Note: async functions not yet fully supported; treating as sync"
574            );
575        }
576        let any_emits_code = fixture
577            .assertions
578            .iter()
579            .any(|a| assertion_emits_code(a, field_resolver));
580        if any_emits_code {
581            let _ = writeln!(
582                out,
583                "    const {result_var} = try {module_name}.{function_name}({args_str});"
584            );
585            for assertion in &fixture.assertions {
586                render_assertion(out, assertion, result_var, field_resolver, enum_fields);
587            }
588        } else {
589            let _ = writeln!(out, "    _ = try {module_name}.{function_name}({args_str});");
590        }
591    }
592
593    let _ = writeln!(out, "}}");
594}
595
596/// Predicate matching `render_assertion`: returns true when the assertion
597/// would emit at least one statement that references the result variable.
598fn assertion_emits_code(assertion: &Assertion, field_resolver: &FieldResolver) -> bool {
599    if let Some(f) = &assertion.field {
600        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
601            return false;
602        }
603    }
604    matches!(
605        assertion.assertion_type.as_str(),
606        "equals"
607            | "contains"
608            | "contains_all"
609            | "not_contains"
610            | "not_empty"
611            | "is_empty"
612            | "starts_with"
613            | "ends_with"
614            | "min_length"
615            | "max_length"
616            | "count_min"
617            | "count_equals"
618            | "is_true"
619            | "is_false"
620            | "greater_than"
621            | "less_than"
622            | "greater_than_or_equal"
623            | "less_than_or_equal"
624            | "contains_any"
625    )
626}
627
628/// Build setup lines and the argument list for the function call.
629fn build_args_and_setup(
630    input: &serde_json::Value,
631    args: &[crate::config::ArgMapping],
632    fixture_id: &str,
633    _module_name: &str,
634) -> (Vec<String>, String) {
635    if args.is_empty() {
636        return (Vec::new(), String::new());
637    }
638
639    let mut setup_lines: Vec<String> = Vec::new();
640    let mut parts: Vec<String> = Vec::new();
641
642    for arg in args {
643        if arg.arg_type == "mock_url" {
644            setup_lines.push(format!(
645                "var {} = try allocator.alloc(u8, std.fmt.bufPrint(undefined, \"{{s}}/fixtures/{fixture_id}\", .{{std.posix.getenv(\"MOCK_SERVER_URL\") orelse \"http://localhost:8080\"}}) catch 0)",
646                arg.name,
647            ));
648            parts.push(arg.name.clone());
649            continue;
650        }
651
652        // The Zig wrapper accepts struct parameters (e.g. `ExtractionConfig`)
653        // as JSON `[]const u8`, converting them to opaque FFI handles via the
654        // `<prefix>_<snake>_from_json` helper at the binding layer. Emit the
655        // fixture's configuration value as a JSON string literal, falling back
656        // to `"{}"` when the fixture omits a config so callers exercise the
657        // default path.
658        if arg.name == "config" && arg.arg_type == "json_object" {
659            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
660            let json_str = match input.get(field) {
661                Some(serde_json::Value::Null) | None => "{}".to_string(),
662                Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
663            };
664            parts.push(format!("\"{}\"", escape_zig(&json_str)));
665            continue;
666        }
667
668        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
669        let val = input.get(field);
670        match val {
671            None | Some(serde_json::Value::Null) if arg.optional => {
672                // Zig functions don't have default arguments, so we must
673                // pass `null` explicitly for every optional parameter.
674                parts.push("null".to_string());
675            }
676            None | Some(serde_json::Value::Null) => {
677                let default_val = match arg.arg_type.as_str() {
678                    "string" => "\"\"".to_string(),
679                    "int" | "integer" => "0".to_string(),
680                    "float" | "number" => "0.0".to_string(),
681                    "bool" | "boolean" => "false".to_string(),
682                    "json_object" => "\"{}\"".to_string(),
683                    _ => "null".to_string(),
684                };
685                parts.push(default_val);
686            }
687            Some(v) => {
688                // For `json_object` arguments other than `config` (handled
689                // above) the Zig binding accepts a JSON `[]const u8`, so we
690                // serialize the entire fixture value as a single JSON string
691                // literal rather than rendering it as a Zig array/struct.
692                if arg.arg_type == "json_object" {
693                    let json_str = serde_json::to_string(v).unwrap_or_default();
694                    parts.push(format!("\"{}\"", escape_zig(&json_str)));
695                } else {
696                    parts.push(json_to_zig(v));
697                }
698            }
699        }
700    }
701
702    (setup_lines, parts.join(", "))
703}
704
705fn render_assertion(
706    out: &mut String,
707    assertion: &Assertion,
708    result_var: &str,
709    field_resolver: &FieldResolver,
710    enum_fields: &HashSet<String>,
711) {
712    // Skip assertions on fields that don't exist on the result type.
713    if let Some(f) = &assertion.field {
714        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
715            let _ = writeln!(out, "    // skipped: field '{{f}}' not available on result type");
716            return;
717        }
718    }
719
720    // Determine if this field is an enum type.
721    let _field_is_enum = assertion
722        .field
723        .as_deref()
724        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
725
726    let field_expr = match &assertion.field {
727        Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
728        _ => result_var.to_string(),
729    };
730
731    match assertion.assertion_type.as_str() {
732        "equals" => {
733            if let Some(expected) = &assertion.value {
734                let zig_val = json_to_zig(expected);
735                let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {field_expr});");
736            }
737        }
738        "contains" => {
739            if let Some(expected) = &assertion.value {
740                let zig_val = json_to_zig(expected);
741                let _ = writeln!(
742                    out,
743                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
744                );
745            }
746        }
747        "contains_all" => {
748            if let Some(values) = &assertion.values {
749                for val in values {
750                    let zig_val = json_to_zig(val);
751                    let _ = writeln!(
752                        out,
753                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
754                    );
755                }
756            }
757        }
758        "not_contains" => {
759            if let Some(expected) = &assertion.value {
760                let zig_val = json_to_zig(expected);
761                let _ = writeln!(
762                    out,
763                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
764                );
765            }
766        }
767        "not_empty" => {
768            let _ = writeln!(out, "    try testing.expect({field_expr}.len > 0);");
769        }
770        "is_empty" => {
771            let _ = writeln!(out, "    try testing.expect({field_expr}.len == 0);");
772        }
773        "starts_with" => {
774            if let Some(expected) = &assertion.value {
775                let zig_val = json_to_zig(expected);
776                let _ = writeln!(
777                    out,
778                    "    try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
779                );
780            }
781        }
782        "ends_with" => {
783            if let Some(expected) = &assertion.value {
784                let zig_val = json_to_zig(expected);
785                let _ = writeln!(
786                    out,
787                    "    try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
788                );
789            }
790        }
791        "min_length" => {
792            if let Some(val) = &assertion.value {
793                if let Some(n) = val.as_u64() {
794                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
795                }
796            }
797        }
798        "max_length" => {
799            if let Some(val) = &assertion.value {
800                if let Some(n) = val.as_u64() {
801                    let _ = writeln!(out, "    try testing.expect({field_expr}.len <= {n});");
802                }
803            }
804        }
805        "count_min" => {
806            if let Some(val) = &assertion.value {
807                if let Some(n) = val.as_u64() {
808                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
809                }
810            }
811        }
812        "count_equals" => {
813            if let Some(val) = &assertion.value {
814                if let Some(n) = val.as_u64() {
815                    let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.len);");
816                }
817            }
818        }
819        "is_true" => {
820            let _ = writeln!(out, "    try testing.expect({field_expr});");
821        }
822        "is_false" => {
823            let _ = writeln!(out, "    try testing.expect(!{field_expr});");
824        }
825        "not_error" => {
826            // Already handled by the call succeeding.
827        }
828        "error" => {
829            // Handled at the test function level.
830        }
831        "greater_than" => {
832            if let Some(val) = &assertion.value {
833                let zig_val = json_to_zig(val);
834                let _ = writeln!(out, "    try testing.expect({field_expr} > {zig_val});");
835            }
836        }
837        "less_than" => {
838            if let Some(val) = &assertion.value {
839                let zig_val = json_to_zig(val);
840                let _ = writeln!(out, "    try testing.expect({field_expr} < {zig_val});");
841            }
842        }
843        "greater_than_or_equal" => {
844            if let Some(val) = &assertion.value {
845                let zig_val = json_to_zig(val);
846                let _ = writeln!(out, "    try testing.expect({field_expr} >= {zig_val});");
847            }
848        }
849        "less_than_or_equal" => {
850            if let Some(val) = &assertion.value {
851                let zig_val = json_to_zig(val);
852                let _ = writeln!(out, "    try testing.expect({field_expr} <= {zig_val});");
853            }
854        }
855        "contains_any" => {
856            if let Some(values) = &assertion.values {
857                for val in values {
858                    let zig_val = json_to_zig(val);
859                    let _ = writeln!(
860                        out,
861                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
862                    );
863                }
864            }
865        }
866        "matches_regex" => {
867            let _ = writeln!(out, "    // regex match not yet implemented for Zig");
868        }
869        "method_result" => {
870            let _ = writeln!(out, "    // method_result assertions not yet implemented for Zig");
871        }
872        other => {
873            panic!("Zig e2e generator: unsupported assertion type: {other}");
874        }
875    }
876}
877
878/// Convert a `serde_json::Value` to a Zig literal string.
879fn json_to_zig(value: &serde_json::Value) -> String {
880    match value {
881        serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
882        serde_json::Value::Bool(b) => b.to_string(),
883        serde_json::Value::Number(n) => n.to_string(),
884        serde_json::Value::Null => "null".to_string(),
885        serde_json::Value::Array(arr) => {
886            let items: Vec<String> = arr.iter().map(json_to_zig).collect();
887            format!("&.{{{}}}", items.join(", "))
888        }
889        serde_json::Value::Object(_) => {
890            let json_str = serde_json::to_string(value).unwrap_or_default();
891            format!("\"{}\"", escape_zig(&json_str))
892        }
893    }
894}