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;
21
22/// Zig e2e code generator.
23pub struct ZigE2eCodegen;
24
25impl E2eCodegen for ZigE2eCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        alef_config: &AlefConfig,
31    ) -> Result<Vec<GeneratedFile>> {
32        let lang = self.language_name();
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35        let mut files = Vec::new();
36
37        // Resolve call config with overrides.
38        let call = &e2e_config.call;
39        let overrides = call.overrides.get(lang);
40        let _module_path = overrides
41            .and_then(|o| o.module.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.module.clone());
44        let function_name = overrides
45            .and_then(|o| o.function.as_ref())
46            .cloned()
47            .unwrap_or_else(|| call.function.clone());
48        let result_var = &call.result_var;
49
50        // Resolve package config.
51        let zig_pkg = e2e_config.resolve_package("zig");
52        let pkg_path = zig_pkg
53            .as_ref()
54            .and_then(|p| p.path.as_ref())
55            .cloned()
56            .unwrap_or_else(|| "../../packages/zig".to_string());
57        let pkg_name = zig_pkg
58            .as_ref()
59            .and_then(|p| p.name.as_ref())
60            .cloned()
61            .unwrap_or_else(|| alef_config.crate_config.name.to_snake_case());
62
63        // Generate build.zig.zon (Zig package manifest).
64        files.push(GeneratedFile {
65            path: output_base.join("build.zig.zon"),
66            content: render_build_zig_zon(&pkg_name, &pkg_path, e2e_config.dep_mode),
67            generated_header: false,
68        });
69
70        // Get the module name for imports.
71        let module_name = alef_config.zig_module_name();
72
73        // Generate build.zig - collect test file names first.
74        let field_resolver = FieldResolver::new(
75            &e2e_config.fields,
76            &e2e_config.fields_optional,
77            &e2e_config.result_fields,
78            &e2e_config.fields_array,
79        );
80
81        // Generate test files per category and collect their names.
82        let mut test_filenames: Vec<String> = Vec::new();
83        for group in groups {
84            let active: Vec<&Fixture> = group
85                .fixtures
86                .iter()
87                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
88                .collect();
89
90            if active.is_empty() {
91                continue;
92            }
93
94            let filename = format!("{}_test.zig", sanitize_filename(&group.category));
95            test_filenames.push(filename.clone());
96            let content = render_test_file(
97                &group.category,
98                &active,
99                e2e_config,
100                &function_name,
101                result_var,
102                &e2e_config.call.args,
103                &field_resolver,
104                &e2e_config.fields_enum,
105                &module_name,
106            );
107            files.push(GeneratedFile {
108                path: output_base.join("src").join(filename),
109                content,
110                generated_header: true,
111            });
112        }
113
114        // Generate build.zig with collected test files.
115        files.insert(
116            files
117                .iter()
118                .position(|f| f.path.file_name().is_some_and(|n| n == "build.zig.zon"))
119                .unwrap_or(1),
120            GeneratedFile {
121                path: output_base.join("build.zig"),
122                content: render_build_zig(&test_filenames),
123                generated_header: false,
124            },
125        );
126
127        Ok(files)
128    }
129
130    fn language_name(&self) -> &'static str {
131        "zig"
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Rendering
137// ---------------------------------------------------------------------------
138
139fn render_build_zig_zon(pkg_name: &str, pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
140    let dep_block = match dep_mode {
141        crate::config::DependencyMode::Registry => {
142            // For registry mode, use a dummy hash (in real Zig, hash must be computed).
143            format!(
144                r#".{{
145            .url = "https://registry.example.com/{pkg_name}/v0.1.0.tar.gz",
146            .hash = "0000000000000000000000000000000000000000000000000000000000000000",
147        }}"#
148            )
149        }
150        crate::config::DependencyMode::Local => {
151            format!(r#".{{ .path = "{pkg_path}" }}"#)
152        }
153    };
154
155    let min_zig = toolchain::MIN_ZIG_VERSION;
156    // Zig 0.16+ requires a fingerprint of the form (crc32_ieee(name) << 32) | id.
157    let name_bytes: &[u8] = b"e2e_zig";
158    let mut crc: u32 = 0xffff_ffff;
159    for byte in name_bytes {
160        crc ^= *byte as u32;
161        for _ in 0..8 {
162            let mask = (crc & 1).wrapping_neg();
163            crc = (crc >> 1) ^ (0xedb8_8320 & mask);
164        }
165    }
166    let name_crc: u32 = !crc;
167    let mut id: u32 = 0x811c_9dc5;
168    for byte in name_bytes {
169        id ^= *byte as u32;
170        id = id.wrapping_mul(0x0100_0193);
171    }
172    if id == 0 || id == 0xffff_ffff {
173        id = 0x1;
174    }
175    let fingerprint: u64 = ((name_crc as u64) << 32) | (id as u64);
176    format!(
177        r#".{{
178    .name = .e2e_zig,
179    .version = "0.1.0",
180    .fingerprint = 0x{fingerprint:016x},
181    .minimum_zig_version = "{min_zig}",
182    .dependencies = .{{
183        .{pkg_name} = {dep_block},
184    }},
185    .paths = .{{
186        "build.zig",
187        "build.zig.zon",
188        "src",
189    }},
190}}
191"#
192    )
193}
194
195fn render_build_zig(test_filenames: &[String]) -> String {
196    if test_filenames.is_empty() {
197        return r#"const std = @import("std");
198
199pub fn build(b: *std.Build) void {
200    const target = b.standardTargetOptions(.{});
201    const optimize = b.standardOptimizeOption(.{});
202
203    const test_step = b.step("test", "Run tests");
204}
205"#
206        .to_string();
207    }
208
209    let mut content = String::from("const std = @import(\"std\");\n\npub fn build(b: *std.Build) void {\n");
210    content.push_str("    const target = b.standardTargetOptions(.{});\n");
211    content.push_str("    const optimize = b.standardOptimizeOption(.{});\n");
212    content.push_str("    const test_step = b.step(\"test\", \"Run tests\");\n\n");
213
214    for filename in test_filenames {
215        // Convert filename like "basic_test.zig" to a test name
216        let test_name = filename.trim_end_matches("_test.zig");
217        content.push_str(&format!("    const {test_name}_module = b.createModule(.{{\n"));
218        content.push_str(&format!("        .root_source_file = b.path(\"src/{filename}\"),\n"));
219        content.push_str("        .target = target,\n");
220        content.push_str("        .optimize = optimize,\n");
221        content.push_str("    });\n");
222        content.push_str(&format!("    const {test_name}_tests = b.addTest(.{{\n"));
223        content.push_str(&format!("        .root_module = {test_name}_module,\n"));
224        content.push_str("    });\n");
225        content.push_str(&format!(
226            "    const {test_name}_run = b.addRunArtifact({test_name}_tests);\n"
227        ));
228        content.push_str(&format!("    test_step.dependOn(&{test_name}_run.step);\n\n"));
229    }
230
231    content.push_str("}\n");
232    content
233}
234
235#[allow(clippy::too_many_arguments)]
236fn render_test_file(
237    category: &str,
238    fixtures: &[&Fixture],
239    e2e_config: &E2eConfig,
240    function_name: &str,
241    result_var: &str,
242    args: &[crate::config::ArgMapping],
243    field_resolver: &FieldResolver,
244    enum_fields: &HashSet<String>,
245    module_name: &str,
246) -> String {
247    let mut out = String::new();
248    out.push_str(&hash::header(CommentStyle::DoubleSlash));
249    let _ = writeln!(out, "const std = @import(\"std\");");
250    let _ = writeln!(out, "const testing = std.testing;");
251    let _ = writeln!(out, "const {module_name} = @import(\"{module_name}\");");
252    let _ = writeln!(out);
253
254    let _ = writeln!(out, "// E2e tests for category: {category}");
255    let _ = writeln!(out);
256
257    for fixture in fixtures {
258        render_test_fn(
259            &mut out,
260            fixture,
261            e2e_config,
262            function_name,
263            result_var,
264            args,
265            field_resolver,
266            enum_fields,
267            module_name,
268        );
269        let _ = writeln!(out);
270    }
271
272    out
273}
274
275#[allow(clippy::too_many_arguments)]
276fn render_test_fn(
277    out: &mut String,
278    fixture: &Fixture,
279    e2e_config: &E2eConfig,
280    _function_name: &str,
281    _result_var: &str,
282    _args: &[crate::config::ArgMapping],
283    field_resolver: &FieldResolver,
284    enum_fields: &HashSet<String>,
285    module_name: &str,
286) {
287    // Resolve per-fixture call config.
288    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
289    let lang = "zig";
290    let call_overrides = call_config.overrides.get(lang);
291    let function_name = call_overrides
292        .and_then(|o| o.function.as_ref())
293        .cloned()
294        .unwrap_or_else(|| call_config.function.clone());
295    let result_var = &call_config.result_var;
296    let args = &call_config.args;
297
298    let test_name = fixture.id.to_snake_case();
299    let description = &fixture.description;
300    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
301
302    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
303
304    let _ = writeln!(out, "test \"{test_name}\" {{");
305    let _ = writeln!(out, "    // {description}");
306
307    // Only emit allocator setup when setup lines actually need it (avoids unused-variable errors).
308    let needs_alloc = !setup_lines.is_empty();
309    if needs_alloc {
310        let _ = writeln!(out, "    var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
311        let _ = writeln!(out, "    defer _ = gpa.deinit();");
312        let _ = writeln!(out, "    const allocator = gpa.allocator();");
313        let _ = writeln!(out);
314    }
315
316    for line in &setup_lines {
317        let _ = writeln!(out, "    {line}");
318    }
319
320    if expects_error {
321        // Stub: error-path tests are not yet callable without a real FFI handle_request.
322        let _ = writeln!(
323            out,
324            "    // TODO: call {module_name}.{function_name}({args_str}) and assert error"
325        );
326        let _ = writeln!(out, "    _ = testing;");
327        let _ = writeln!(out, "}}");
328        return;
329    }
330
331    if fixture.assertions.is_empty() {
332        // No assertions: emit a compilation-only stub so the test passes trivially.
333        let _ = writeln!(out, "    // TODO: call {module_name}.{function_name}({args_str})");
334        let _ = writeln!(out, "    _ = testing;");
335    } else {
336        let _ = writeln!(
337            out,
338            "    const {result_var} = {module_name}.{function_name}({args_str});"
339        );
340        for assertion in &fixture.assertions {
341            render_assertion(out, assertion, result_var, field_resolver, enum_fields);
342        }
343    }
344
345    let _ = writeln!(out, "}}");
346}
347
348/// Build setup lines and the argument list for the function call.
349fn build_args_and_setup(
350    input: &serde_json::Value,
351    args: &[crate::config::ArgMapping],
352    fixture_id: &str,
353) -> (Vec<String>, String) {
354    if args.is_empty() {
355        return (Vec::new(), String::new());
356    }
357
358    let mut setup_lines: Vec<String> = Vec::new();
359    let mut parts: Vec<String> = Vec::new();
360
361    for arg in args {
362        if arg.arg_type == "mock_url" {
363            setup_lines.push(format!(
364                "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)",
365                arg.name,
366            ));
367            parts.push(arg.name.clone());
368            continue;
369        }
370
371        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
372        let val = input.get(field);
373        match val {
374            None | Some(serde_json::Value::Null) if arg.optional => {
375                continue;
376            }
377            None | Some(serde_json::Value::Null) => {
378                let default_val = match arg.arg_type.as_str() {
379                    "string" => "\"\"".to_string(),
380                    "int" | "integer" => "0".to_string(),
381                    "float" | "number" => "0.0".to_string(),
382                    "bool" | "boolean" => "false".to_string(),
383                    _ => "null".to_string(),
384                };
385                parts.push(default_val);
386            }
387            Some(v) => {
388                parts.push(json_to_zig(v));
389            }
390        }
391    }
392
393    (setup_lines, parts.join(", "))
394}
395
396fn render_assertion(
397    out: &mut String,
398    assertion: &Assertion,
399    result_var: &str,
400    field_resolver: &FieldResolver,
401    enum_fields: &HashSet<String>,
402) {
403    // Skip assertions on fields that don't exist on the result type.
404    if let Some(f) = &assertion.field {
405        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
406            let _ = writeln!(out, "    // skipped: field '{{f}}' not available on result type");
407            return;
408        }
409    }
410
411    // Determine if this field is an enum type.
412    let _field_is_enum = assertion
413        .field
414        .as_deref()
415        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
416
417    let field_expr = match &assertion.field {
418        Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
419        _ => result_var.to_string(),
420    };
421
422    match assertion.assertion_type.as_str() {
423        "equals" => {
424            if let Some(expected) = &assertion.value {
425                let zig_val = json_to_zig(expected);
426                let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {field_expr});");
427            }
428        }
429        "contains" => {
430            if let Some(expected) = &assertion.value {
431                let zig_val = json_to_zig(expected);
432                let _ = writeln!(
433                    out,
434                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
435                );
436            }
437        }
438        "contains_all" => {
439            if let Some(values) = &assertion.values {
440                for val in values {
441                    let zig_val = json_to_zig(val);
442                    let _ = writeln!(
443                        out,
444                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
445                    );
446                }
447            }
448        }
449        "not_contains" => {
450            if let Some(expected) = &assertion.value {
451                let zig_val = json_to_zig(expected);
452                let _ = writeln!(
453                    out,
454                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
455                );
456            }
457        }
458        "not_empty" => {
459            let _ = writeln!(out, "    try testing.expect({field_expr}.len > 0);");
460        }
461        "is_empty" => {
462            let _ = writeln!(out, "    try testing.expect({field_expr}.len == 0);");
463        }
464        "starts_with" => {
465            if let Some(expected) = &assertion.value {
466                let zig_val = json_to_zig(expected);
467                let _ = writeln!(
468                    out,
469                    "    try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
470                );
471            }
472        }
473        "ends_with" => {
474            if let Some(expected) = &assertion.value {
475                let zig_val = json_to_zig(expected);
476                let _ = writeln!(
477                    out,
478                    "    try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
479                );
480            }
481        }
482        "min_length" => {
483            if let Some(val) = &assertion.value {
484                if let Some(n) = val.as_u64() {
485                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
486                }
487            }
488        }
489        "max_length" => {
490            if let Some(val) = &assertion.value {
491                if let Some(n) = val.as_u64() {
492                    let _ = writeln!(out, "    try testing.expect({field_expr}.len <= {n});");
493                }
494            }
495        }
496        "count_min" => {
497            if let Some(val) = &assertion.value {
498                if let Some(n) = val.as_u64() {
499                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
500                }
501            }
502        }
503        "count_equals" => {
504            if let Some(val) = &assertion.value {
505                if let Some(n) = val.as_u64() {
506                    let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.len);");
507                }
508            }
509        }
510        "is_true" => {
511            let _ = writeln!(out, "    try testing.expect({field_expr});");
512        }
513        "is_false" => {
514            let _ = writeln!(out, "    try testing.expect(!{field_expr});");
515        }
516        "not_error" => {
517            // Already handled by the call succeeding.
518        }
519        "error" => {
520            // Handled at the test function level.
521        }
522        "greater_than" => {
523            if let Some(val) = &assertion.value {
524                let zig_val = json_to_zig(val);
525                let _ = writeln!(out, "    try testing.expect({field_expr} > {zig_val});");
526            }
527        }
528        "less_than" => {
529            if let Some(val) = &assertion.value {
530                let zig_val = json_to_zig(val);
531                let _ = writeln!(out, "    try testing.expect({field_expr} < {zig_val});");
532            }
533        }
534        "greater_than_or_equal" => {
535            if let Some(val) = &assertion.value {
536                let zig_val = json_to_zig(val);
537                let _ = writeln!(out, "    try testing.expect({field_expr} >= {zig_val});");
538            }
539        }
540        "less_than_or_equal" => {
541            if let Some(val) = &assertion.value {
542                let zig_val = json_to_zig(val);
543                let _ = writeln!(out, "    try testing.expect({field_expr} <= {zig_val});");
544            }
545        }
546        "contains_any" => {
547            if let Some(values) = &assertion.values {
548                for val in values {
549                    let zig_val = json_to_zig(val);
550                    let _ = writeln!(
551                        out,
552                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
553                    );
554                }
555            }
556        }
557        "matches_regex" => {
558            let _ = writeln!(out, "    // regex match not yet implemented for Zig");
559        }
560        "method_result" => {
561            let _ = writeln!(out, "    // method_result assertions not yet implemented for Zig");
562        }
563        other => {
564            panic!("Zig e2e generator: unsupported assertion type: {other}");
565        }
566    }
567}
568
569/// Convert a `serde_json::Value` to a Zig literal string.
570fn json_to_zig(value: &serde_json::Value) -> String {
571    match value {
572        serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
573        serde_json::Value::Bool(b) => b.to_string(),
574        serde_json::Value::Number(n) => n.to_string(),
575        serde_json::Value::Null => "null".to_string(),
576        serde_json::Value::Array(arr) => {
577            let items: Vec<String> = arr.iter().map(json_to_zig).collect();
578            format!("&.{{{}}}", items.join(", "))
579        }
580        serde_json::Value::Object(_) => {
581            let json_str = serde_json::to_string(value).unwrap_or_default();
582            format!("\"{}\"", escape_zig(&json_str))
583        }
584    }
585}