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::AlefConfig;
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        alef_config: &AlefConfig,
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(|| alef_config.crate_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 = alef_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        );
81
82        // Generate test files per category and collect their names.
83        let mut test_filenames: Vec<String> = Vec::new();
84        for group in groups {
85            let active: Vec<&Fixture> = group
86                .fixtures
87                .iter()
88                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
89                .collect();
90
91            if active.is_empty() {
92                continue;
93            }
94
95            let filename = format!("{}_test.zig", sanitize_filename(&group.category));
96            test_filenames.push(filename.clone());
97            let content = render_test_file(
98                &group.category,
99                &active,
100                e2e_config,
101                &function_name,
102                result_var,
103                &e2e_config.call.args,
104                &field_resolver,
105                &e2e_config.fields_enum,
106                &module_name,
107            );
108            files.push(GeneratedFile {
109                path: output_base.join("src").join(filename),
110                content,
111                generated_header: true,
112            });
113        }
114
115        // Generate build.zig with collected test files.
116        files.insert(
117            files
118                .iter()
119                .position(|f| f.path.file_name().is_some_and(|n| n == "build.zig.zon"))
120                .unwrap_or(1),
121            GeneratedFile {
122                path: output_base.join("build.zig"),
123                content: render_build_zig(&test_filenames),
124                generated_header: false,
125            },
126        );
127
128        Ok(files)
129    }
130
131    fn language_name(&self) -> &'static str {
132        "zig"
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Rendering
138// ---------------------------------------------------------------------------
139
140fn render_build_zig_zon(pkg_name: &str, pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
141    let dep_block = match dep_mode {
142        crate::config::DependencyMode::Registry => {
143            // For registry mode, use a dummy hash (in real Zig, hash must be computed).
144            format!(
145                r#".{{
146            .url = "https://registry.example.com/{pkg_name}/v0.1.0.tar.gz",
147            .hash = "0000000000000000000000000000000000000000000000000000000000000000",
148        }}"#
149            )
150        }
151        crate::config::DependencyMode::Local => {
152            format!(r#".{{ .path = "{pkg_path}" }}"#)
153        }
154    };
155
156    let min_zig = toolchain::MIN_ZIG_VERSION;
157    // Zig 0.16+ requires a fingerprint of the form (crc32_ieee(name) << 32) | id.
158    let name_bytes: &[u8] = b"e2e_zig";
159    let mut crc: u32 = 0xffff_ffff;
160    for byte in name_bytes {
161        crc ^= *byte as u32;
162        for _ in 0..8 {
163            let mask = (crc & 1).wrapping_neg();
164            crc = (crc >> 1) ^ (0xedb8_8320 & mask);
165        }
166    }
167    let name_crc: u32 = !crc;
168    let mut id: u32 = 0x811c_9dc5;
169    for byte in name_bytes {
170        id ^= *byte as u32;
171        id = id.wrapping_mul(0x0100_0193);
172    }
173    if id == 0 || id == 0xffff_ffff {
174        id = 0x1;
175    }
176    let fingerprint: u64 = ((name_crc as u64) << 32) | (id as u64);
177    format!(
178        r#".{{
179    .name = .e2e_zig,
180    .version = "0.1.0",
181    .fingerprint = 0x{fingerprint:016x},
182    .minimum_zig_version = "{min_zig}",
183    .dependencies = .{{
184        .{pkg_name} = {dep_block},
185    }},
186    .paths = .{{
187        "build.zig",
188        "build.zig.zon",
189        "src",
190    }},
191}}
192"#
193    )
194}
195
196fn render_build_zig(test_filenames: &[String]) -> String {
197    if test_filenames.is_empty() {
198        return r#"const std = @import("std");
199
200pub fn build(b: *std.Build) void {
201    const target = b.standardTargetOptions(.{});
202    const optimize = b.standardOptimizeOption(.{});
203
204    const test_step = b.step("test", "Run tests");
205}
206"#
207        .to_string();
208    }
209
210    let mut content = String::from("const std = @import(\"std\");\n\npub fn build(b: *std.Build) void {\n");
211    content.push_str("    const target = b.standardTargetOptions(.{});\n");
212    content.push_str("    const optimize = b.standardOptimizeOption(.{});\n");
213    content.push_str("    const test_step = b.step(\"test\", \"Run tests\");\n\n");
214
215    for filename in test_filenames {
216        // Convert filename like "basic_test.zig" to a test name
217        let test_name = filename.trim_end_matches("_test.zig");
218        content.push_str(&format!("    const {test_name}_module = b.createModule(.{{\n"));
219        content.push_str(&format!("        .root_source_file = b.path(\"src/{filename}\"),\n"));
220        content.push_str("        .target = target,\n");
221        content.push_str("        .optimize = optimize,\n");
222        content.push_str("    });\n");
223        content.push_str(&format!("    const {test_name}_tests = b.addTest(.{{\n"));
224        content.push_str(&format!("        .root_module = {test_name}_module,\n"));
225        content.push_str("    });\n");
226        content.push_str(&format!(
227            "    const {test_name}_run = b.addRunArtifact({test_name}_tests);\n"
228        ));
229        content.push_str(&format!("    test_step.dependOn(&{test_name}_run.step);\n\n"));
230    }
231
232    content.push_str("}\n");
233    content
234}
235
236// ---------------------------------------------------------------------------
237// HTTP server test rendering — shared-driver integration
238// ---------------------------------------------------------------------------
239
240/// Renderer that emits Zig `test "..." { ... }` blocks targeting a mock server
241/// via `std.http.Client`. Satisfies [`client::TestClientRenderer`] so the shared
242/// [`client::http_call::render_http_test`] driver drives the call sequence.
243struct ZigTestClientRenderer;
244
245impl client::TestClientRenderer for ZigTestClientRenderer {
246    fn language_name(&self) -> &'static str {
247        "zig"
248    }
249
250    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
251        if let Some(reason) = skip_reason {
252            let _ = writeln!(out, "test \"{fn_name}\" {{");
253            let _ = writeln!(out, "    // {description}");
254            let _ = writeln!(out, "    // skipped: {reason}");
255            let _ = writeln!(out, "    return error.SkipZigTest;");
256        } else {
257            let _ = writeln!(out, "test \"{fn_name}\" {{");
258            let _ = writeln!(out, "    // {description}");
259        }
260    }
261
262    fn render_test_close(&self, out: &mut String) {
263        let _ = writeln!(out, "}}");
264    }
265
266    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
267        let method = ctx.method.to_uppercase();
268        let fixture_id = ctx.path.trim_start_matches("/fixtures/");
269
270        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
271        let _ = writeln!(out, "    defer _ = gpa.deinit();");
272        let _ = writeln!(out, "    const allocator = gpa.allocator();");
273
274        let _ = writeln!(out, "    var url_buf: [512]u8 = undefined;");
275        let _ = writeln!(
276            out,
277            "    const url = try std.fmt.bufPrint(&url_buf, \"{{s}}/fixtures/{fixture_id}\", .{{std.posix.getenv(\"MOCK_SERVER_URL\") orelse \"http://localhost:8080\"}});"
278        );
279
280        // Headers
281        if !ctx.headers.is_empty() {
282            let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
283            header_pairs.sort_by_key(|(k, _)| k.as_str());
284            let _ = writeln!(out, "    const headers = [_]std.http.Header{{");
285            for (k, v) in &header_pairs {
286                let ek = escape_zig(k);
287                let ev = escape_zig(v);
288                let _ = writeln!(out, "        .{{ .name = \"{ek}\", .value = \"{ev}\" }},");
289            }
290            let _ = writeln!(out, "    }};");
291        }
292
293        // Body
294        if let Some(body) = ctx.body {
295            let json_str = serde_json::to_string(body).unwrap_or_default();
296            let escaped = escape_zig(&json_str);
297            let _ = writeln!(out, "    const body_bytes: []const u8 = \"{escaped}\";");
298        }
299
300        let headers_arg = if ctx.headers.is_empty() { "&.{}" } else { "&headers" };
301        let has_body = ctx.body.is_some();
302
303        let _ = writeln!(
304            out,
305            "    var http_client = std.http.Client{{ .allocator = allocator }};"
306        );
307        let _ = writeln!(out, "    defer http_client.deinit();");
308        let _ = writeln!(out, "    var response_body = std.ArrayList(u8).init(allocator);");
309        let _ = writeln!(out, "    defer response_body.deinit();");
310
311        let method_zig = match method.as_str() {
312            "GET" => ".GET",
313            "POST" => ".POST",
314            "PUT" => ".PUT",
315            "DELETE" => ".DELETE",
316            "PATCH" => ".PATCH",
317            "HEAD" => ".HEAD",
318            "OPTIONS" => ".OPTIONS",
319            _ => ".GET",
320        };
321
322        let payload_field = if has_body { ", .payload = body_bytes" } else { "" };
323        let _ = writeln!(
324            out,
325            "    const {rv} = try http_client.fetch(.{{ .location = .{{ .url = url }}, .method = {method_zig}, .extra_headers = {headers_arg}{payload_field}, .response_storage = .{{ .dynamic = &response_body }} }});",
326            rv = ctx.response_var,
327        );
328    }
329
330    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
331        let _ = writeln!(
332            out,
333            "    try testing.expectEqual(@as(u10, {status}), @intFromEnum({response_var}.status));"
334        );
335    }
336
337    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
338        let ename = escape_zig(&name.to_lowercase());
339        match expected {
340            "<<present>>" => {
341                let _ = writeln!(
342                    out,
343                    "    // assert header '{ename}' is present (header inspection not yet implemented)"
344                );
345            }
346            "<<absent>>" => {
347                let _ = writeln!(
348                    out,
349                    "    // assert header '{ename}' is absent (header inspection not yet implemented)"
350                );
351            }
352            "<<uuid>>" => {
353                let _ = writeln!(
354                    out,
355                    "    // assert header '{ename}' matches UUID pattern (header inspection not yet implemented)"
356                );
357            }
358            exact => {
359                let evalue = escape_zig(exact);
360                let _ = writeln!(
361                    out,
362                    "    // assert header '{ename}' == \"{evalue}\" (header inspection not yet implemented)"
363                );
364            }
365        }
366    }
367
368    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
369        let json_str = serde_json::to_string(expected).unwrap_or_default();
370        let escaped = escape_zig(&json_str);
371        let _ = writeln!(
372            out,
373            "    try testing.expectEqualStrings(\"{escaped}\", response_body.items);"
374        );
375    }
376
377    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
378        if let Some(obj) = expected.as_object() {
379            for (key, val) in obj {
380                let ekey = escape_zig(key);
381                let eval = escape_zig(&serde_json::to_string(val).unwrap_or_default());
382                let _ = writeln!(
383                    out,
384                    "    // assert body contains field \"{ekey}\" = \"{eval}\" (partial JSON not yet implemented)"
385                );
386            }
387        }
388    }
389
390    fn render_assert_validation_errors(
391        &self,
392        out: &mut String,
393        _response_var: &str,
394        errors: &[crate::fixture::ValidationErrorExpectation],
395    ) {
396        for ve in errors {
397            let loc = ve.loc.join(".");
398            let escaped_loc = escape_zig(&loc);
399            let escaped_msg = escape_zig(&ve.msg);
400            let _ = writeln!(
401                out,
402                "    // assert validation error at \"{escaped_loc}\": \"{escaped_msg}\" (not yet implemented)"
403            );
404        }
405    }
406}
407
408/// Render a Zig `test "..." { ... }` block for an HTTP server fixture.
409///
410/// Delegates to the shared [`client::http_call::render_http_test`] driver via
411/// [`ZigTestClientRenderer`].
412fn render_http_test_case(out: &mut String, fixture: &Fixture) {
413    client::http_call::render_http_test(out, &ZigTestClientRenderer, fixture);
414}
415
416// ---------------------------------------------------------------------------
417// Function-call test rendering
418// ---------------------------------------------------------------------------
419
420#[allow(clippy::too_many_arguments)]
421fn render_test_file(
422    category: &str,
423    fixtures: &[&Fixture],
424    e2e_config: &E2eConfig,
425    function_name: &str,
426    result_var: &str,
427    args: &[crate::config::ArgMapping],
428    field_resolver: &FieldResolver,
429    enum_fields: &HashSet<String>,
430    module_name: &str,
431) -> String {
432    let mut out = String::new();
433    out.push_str(&hash::header(CommentStyle::DoubleSlash));
434    let _ = writeln!(out, "const std = @import(\"std\");");
435    let _ = writeln!(out, "const testing = std.testing;");
436    let _ = writeln!(out, "const {module_name} = @import(\"{module_name}\");");
437    let _ = writeln!(out);
438
439    let _ = writeln!(out, "// E2e tests for category: {category}");
440    let _ = writeln!(out);
441
442    for fixture in fixtures {
443        if fixture.http.is_some() {
444            render_http_test_case(&mut out, fixture);
445        } else {
446            render_test_fn(
447                &mut out,
448                fixture,
449                e2e_config,
450                function_name,
451                result_var,
452                args,
453                field_resolver,
454                enum_fields,
455                module_name,
456            );
457        }
458        let _ = writeln!(out);
459    }
460
461    out
462}
463
464#[allow(clippy::too_many_arguments)]
465fn render_test_fn(
466    out: &mut String,
467    fixture: &Fixture,
468    e2e_config: &E2eConfig,
469    _function_name: &str,
470    _result_var: &str,
471    _args: &[crate::config::ArgMapping],
472    field_resolver: &FieldResolver,
473    enum_fields: &HashSet<String>,
474    module_name: &str,
475) {
476    // Resolve per-fixture call config.
477    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
478    let lang = "zig";
479    let call_overrides = call_config.overrides.get(lang);
480    let function_name = call_overrides
481        .and_then(|o| o.function.as_ref())
482        .cloned()
483        .unwrap_or_else(|| call_config.function.clone());
484    let result_var = &call_config.result_var;
485    let args = &call_config.args;
486
487    let test_name = fixture.id.to_snake_case();
488    let description = &fixture.description;
489    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
490
491    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
492
493    let _ = writeln!(out, "test \"{test_name}\" {{");
494    let _ = writeln!(out, "    // {description}");
495
496    // Only emit allocator setup when setup lines actually need it (avoids unused-variable errors).
497    let needs_alloc = !setup_lines.is_empty();
498    if needs_alloc {
499        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
500        let _ = writeln!(out, "    defer _ = gpa.deinit();");
501        let _ = writeln!(out, "    const allocator = gpa.allocator();");
502        let _ = writeln!(out);
503    }
504
505    for line in &setup_lines {
506        let _ = writeln!(out, "    {line}");
507    }
508
509    if expects_error {
510        // Stub: error-path tests are not yet callable without a real FFI handle_request.
511        let _ = writeln!(
512            out,
513            "    // TODO: call {module_name}.{function_name}({args_str}) and assert error"
514        );
515        let _ = writeln!(out, "    _ = testing;");
516        let _ = writeln!(out, "}}");
517        return;
518    }
519
520    if fixture.assertions.is_empty() {
521        // No assertions: emit a compilation-only stub so the test passes trivially.
522        let _ = writeln!(out, "    // TODO: call {module_name}.{function_name}({args_str})");
523        let _ = writeln!(out, "    _ = testing;");
524    } else {
525        let _ = writeln!(
526            out,
527            "    const {result_var} = {module_name}.{function_name}({args_str});"
528        );
529        for assertion in &fixture.assertions {
530            render_assertion(out, assertion, result_var, field_resolver, enum_fields);
531        }
532    }
533
534    let _ = writeln!(out, "}}");
535}
536
537/// Build setup lines and the argument list for the function call.
538fn build_args_and_setup(
539    input: &serde_json::Value,
540    args: &[crate::config::ArgMapping],
541    fixture_id: &str,
542) -> (Vec<String>, String) {
543    if args.is_empty() {
544        return (Vec::new(), String::new());
545    }
546
547    let mut setup_lines: Vec<String> = Vec::new();
548    let mut parts: Vec<String> = Vec::new();
549
550    for arg in args {
551        if arg.arg_type == "mock_url" {
552            setup_lines.push(format!(
553                "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)",
554                arg.name,
555            ));
556            parts.push(arg.name.clone());
557            continue;
558        }
559
560        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
561        let val = input.get(field);
562        match val {
563            None | Some(serde_json::Value::Null) if arg.optional => {
564                continue;
565            }
566            None | Some(serde_json::Value::Null) => {
567                let default_val = match arg.arg_type.as_str() {
568                    "string" => "\"\"".to_string(),
569                    "int" | "integer" => "0".to_string(),
570                    "float" | "number" => "0.0".to_string(),
571                    "bool" | "boolean" => "false".to_string(),
572                    _ => "null".to_string(),
573                };
574                parts.push(default_val);
575            }
576            Some(v) => {
577                parts.push(json_to_zig(v));
578            }
579        }
580    }
581
582    (setup_lines, parts.join(", "))
583}
584
585fn render_assertion(
586    out: &mut String,
587    assertion: &Assertion,
588    result_var: &str,
589    field_resolver: &FieldResolver,
590    enum_fields: &HashSet<String>,
591) {
592    // Skip assertions on fields that don't exist on the result type.
593    if let Some(f) = &assertion.field {
594        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
595            let _ = writeln!(out, "    // skipped: field '{{f}}' not available on result type");
596            return;
597        }
598    }
599
600    // Determine if this field is an enum type.
601    let _field_is_enum = assertion
602        .field
603        .as_deref()
604        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
605
606    let field_expr = match &assertion.field {
607        Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
608        _ => result_var.to_string(),
609    };
610
611    match assertion.assertion_type.as_str() {
612        "equals" => {
613            if let Some(expected) = &assertion.value {
614                let zig_val = json_to_zig(expected);
615                let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {field_expr});");
616            }
617        }
618        "contains" => {
619            if let Some(expected) = &assertion.value {
620                let zig_val = json_to_zig(expected);
621                let _ = writeln!(
622                    out,
623                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
624                );
625            }
626        }
627        "contains_all" => {
628            if let Some(values) = &assertion.values {
629                for val in values {
630                    let zig_val = json_to_zig(val);
631                    let _ = writeln!(
632                        out,
633                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
634                    );
635                }
636            }
637        }
638        "not_contains" => {
639            if let Some(expected) = &assertion.value {
640                let zig_val = json_to_zig(expected);
641                let _ = writeln!(
642                    out,
643                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
644                );
645            }
646        }
647        "not_empty" => {
648            let _ = writeln!(out, "    try testing.expect({field_expr}.len > 0);");
649        }
650        "is_empty" => {
651            let _ = writeln!(out, "    try testing.expect({field_expr}.len == 0);");
652        }
653        "starts_with" => {
654            if let Some(expected) = &assertion.value {
655                let zig_val = json_to_zig(expected);
656                let _ = writeln!(
657                    out,
658                    "    try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
659                );
660            }
661        }
662        "ends_with" => {
663            if let Some(expected) = &assertion.value {
664                let zig_val = json_to_zig(expected);
665                let _ = writeln!(
666                    out,
667                    "    try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
668                );
669            }
670        }
671        "min_length" => {
672            if let Some(val) = &assertion.value {
673                if let Some(n) = val.as_u64() {
674                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
675                }
676            }
677        }
678        "max_length" => {
679            if let Some(val) = &assertion.value {
680                if let Some(n) = val.as_u64() {
681                    let _ = writeln!(out, "    try testing.expect({field_expr}.len <= {n});");
682                }
683            }
684        }
685        "count_min" => {
686            if let Some(val) = &assertion.value {
687                if let Some(n) = val.as_u64() {
688                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
689                }
690            }
691        }
692        "count_equals" => {
693            if let Some(val) = &assertion.value {
694                if let Some(n) = val.as_u64() {
695                    let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.len);");
696                }
697            }
698        }
699        "is_true" => {
700            let _ = writeln!(out, "    try testing.expect({field_expr});");
701        }
702        "is_false" => {
703            let _ = writeln!(out, "    try testing.expect(!{field_expr});");
704        }
705        "not_error" => {
706            // Already handled by the call succeeding.
707        }
708        "error" => {
709            // Handled at the test function level.
710        }
711        "greater_than" => {
712            if let Some(val) = &assertion.value {
713                let zig_val = json_to_zig(val);
714                let _ = writeln!(out, "    try testing.expect({field_expr} > {zig_val});");
715            }
716        }
717        "less_than" => {
718            if let Some(val) = &assertion.value {
719                let zig_val = json_to_zig(val);
720                let _ = writeln!(out, "    try testing.expect({field_expr} < {zig_val});");
721            }
722        }
723        "greater_than_or_equal" => {
724            if let Some(val) = &assertion.value {
725                let zig_val = json_to_zig(val);
726                let _ = writeln!(out, "    try testing.expect({field_expr} >= {zig_val});");
727            }
728        }
729        "less_than_or_equal" => {
730            if let Some(val) = &assertion.value {
731                let zig_val = json_to_zig(val);
732                let _ = writeln!(out, "    try testing.expect({field_expr} <= {zig_val});");
733            }
734        }
735        "contains_any" => {
736            if let Some(values) = &assertion.values {
737                for val in values {
738                    let zig_val = json_to_zig(val);
739                    let _ = writeln!(
740                        out,
741                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
742                    );
743                }
744            }
745        }
746        "matches_regex" => {
747            let _ = writeln!(out, "    // regex match not yet implemented for Zig");
748        }
749        "method_result" => {
750            let _ = writeln!(out, "    // method_result assertions not yet implemented for Zig");
751        }
752        other => {
753            panic!("Zig e2e generator: unsupported assertion type: {other}");
754        }
755    }
756}
757
758/// Convert a `serde_json::Value` to a Zig literal string.
759fn json_to_zig(value: &serde_json::Value) -> String {
760    match value {
761        serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
762        serde_json::Value::Bool(b) => b.to_string(),
763        serde_json::Value::Number(n) => n.to_string(),
764        serde_json::Value::Null => "null".to_string(),
765        serde_json::Value::Array(arr) => {
766            let items: Vec<String> = arr.iter().map(json_to_zig).collect();
767            format!("&.{{{}}}", items.join(", "))
768        }
769        serde_json::Value::Object(_) => {
770            let json_str = serde_json::to_string(value).unwrap_or_default();
771            format!("\"{}\"", escape_zig(&json_str))
772        }
773    }
774}