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    let _ = writeln!(out, "    var gpa = std.heap.GeneralPurposeAllocator(.{{}}){{}}");
307    let _ = writeln!(out, "    defer _ = gpa.deinit();");
308    let _ = writeln!(out, "    const allocator = gpa.allocator();");
309    let _ = writeln!(out);
310
311    for line in &setup_lines {
312        let _ = writeln!(out, "    {line}");
313    }
314
315    if expects_error {
316        let _ = writeln!(out, "    const result = {module_name}.{function_name}({args_str});");
317        let _ = writeln!(
318            out,
319            "    try testing.expect(@typeInfo(@TypeOf(result)) == .ErrorUnion);"
320        );
321        return;
322    }
323
324    let _ = writeln!(
325        out,
326        "    const {result_var} = {module_name}.{function_name}({args_str});"
327    );
328
329    for assertion in &fixture.assertions {
330        render_assertion(out, assertion, result_var, field_resolver, enum_fields);
331    }
332
333    let _ = writeln!(out, "}}");
334}
335
336/// Build setup lines and the argument list for the function call.
337fn build_args_and_setup(
338    input: &serde_json::Value,
339    args: &[crate::config::ArgMapping],
340    fixture_id: &str,
341) -> (Vec<String>, String) {
342    if args.is_empty() {
343        return (Vec::new(), String::new());
344    }
345
346    let mut setup_lines: Vec<String> = Vec::new();
347    let mut parts: Vec<String> = Vec::new();
348
349    for arg in args {
350        if arg.arg_type == "mock_url" {
351            setup_lines.push(format!(
352                "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)",
353                arg.name,
354            ));
355            parts.push(arg.name.clone());
356            continue;
357        }
358
359        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
360        let val = input.get(field);
361        match val {
362            None | Some(serde_json::Value::Null) if arg.optional => {
363                continue;
364            }
365            None | Some(serde_json::Value::Null) => {
366                let default_val = match arg.arg_type.as_str() {
367                    "string" => "\"\"".to_string(),
368                    "int" | "integer" => "0".to_string(),
369                    "float" | "number" => "0.0".to_string(),
370                    "bool" | "boolean" => "false".to_string(),
371                    _ => "null".to_string(),
372                };
373                parts.push(default_val);
374            }
375            Some(v) => {
376                parts.push(json_to_zig(v));
377            }
378        }
379    }
380
381    (setup_lines, parts.join(", "))
382}
383
384fn render_assertion(
385    out: &mut String,
386    assertion: &Assertion,
387    result_var: &str,
388    field_resolver: &FieldResolver,
389    enum_fields: &HashSet<String>,
390) {
391    // Skip assertions on fields that don't exist on the result type.
392    if let Some(f) = &assertion.field {
393        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
394            let _ = writeln!(out, "    // skipped: field '{{f}}' not available on result type");
395            return;
396        }
397    }
398
399    // Determine if this field is an enum type.
400    let _field_is_enum = assertion
401        .field
402        .as_deref()
403        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
404
405    let field_expr = match &assertion.field {
406        Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
407        _ => result_var.to_string(),
408    };
409
410    match assertion.assertion_type.as_str() {
411        "equals" => {
412            if let Some(expected) = &assertion.value {
413                let zig_val = json_to_zig(expected);
414                let _ = writeln!(out, "    try testing.expectEqual({zig_val}, {field_expr});");
415            }
416        }
417        "contains" => {
418            if let Some(expected) = &assertion.value {
419                let zig_val = json_to_zig(expected);
420                let _ = writeln!(
421                    out,
422                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
423                );
424            }
425        }
426        "contains_all" => {
427            if let Some(values) = &assertion.values {
428                for val in values {
429                    let zig_val = json_to_zig(val);
430                    let _ = writeln!(
431                        out,
432                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
433                    );
434                }
435            }
436        }
437        "not_contains" => {
438            if let Some(expected) = &assertion.value {
439                let zig_val = json_to_zig(expected);
440                let _ = writeln!(
441                    out,
442                    "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
443                );
444            }
445        }
446        "not_empty" => {
447            let _ = writeln!(out, "    try testing.expect({field_expr}.len > 0);");
448        }
449        "is_empty" => {
450            let _ = writeln!(out, "    try testing.expect({field_expr}.len == 0);");
451        }
452        "starts_with" => {
453            if let Some(expected) = &assertion.value {
454                let zig_val = json_to_zig(expected);
455                let _ = writeln!(
456                    out,
457                    "    try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
458                );
459            }
460        }
461        "ends_with" => {
462            if let Some(expected) = &assertion.value {
463                let zig_val = json_to_zig(expected);
464                let _ = writeln!(
465                    out,
466                    "    try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
467                );
468            }
469        }
470        "min_length" => {
471            if let Some(val) = &assertion.value {
472                if let Some(n) = val.as_u64() {
473                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
474                }
475            }
476        }
477        "max_length" => {
478            if let Some(val) = &assertion.value {
479                if let Some(n) = val.as_u64() {
480                    let _ = writeln!(out, "    try testing.expect({field_expr}.len <= {n});");
481                }
482            }
483        }
484        "count_min" => {
485            if let Some(val) = &assertion.value {
486                if let Some(n) = val.as_u64() {
487                    let _ = writeln!(out, "    try testing.expect({field_expr}.len >= {n});");
488                }
489            }
490        }
491        "count_equals" => {
492            if let Some(val) = &assertion.value {
493                if let Some(n) = val.as_u64() {
494                    let _ = writeln!(out, "    try testing.expectEqual({n}, {field_expr}.len);");
495                }
496            }
497        }
498        "is_true" => {
499            let _ = writeln!(out, "    try testing.expect({field_expr});");
500        }
501        "is_false" => {
502            let _ = writeln!(out, "    try testing.expect(!{field_expr});");
503        }
504        "not_error" => {
505            // Already handled by the call succeeding.
506        }
507        "error" => {
508            // Handled at the test function level.
509        }
510        "greater_than" => {
511            if let Some(val) = &assertion.value {
512                let zig_val = json_to_zig(val);
513                let _ = writeln!(out, "    try testing.expect({field_expr} > {zig_val});");
514            }
515        }
516        "less_than" => {
517            if let Some(val) = &assertion.value {
518                let zig_val = json_to_zig(val);
519                let _ = writeln!(out, "    try testing.expect({field_expr} < {zig_val});");
520            }
521        }
522        "greater_than_or_equal" => {
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_or_equal" => {
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        "contains_any" => {
535            if let Some(values) = &assertion.values {
536                for val in values {
537                    let zig_val = json_to_zig(val);
538                    let _ = writeln!(
539                        out,
540                        "    try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
541                    );
542                }
543            }
544        }
545        "matches_regex" => {
546            let _ = writeln!(out, "    // regex match not yet implemented for Zig");
547        }
548        "method_result" => {
549            let _ = writeln!(out, "    // method_result assertions not yet implemented for Zig");
550        }
551        other => {
552            panic!("Zig e2e generator: unsupported assertion type: {other}");
553        }
554    }
555}
556
557/// Convert a `serde_json::Value` to a Zig literal string.
558fn json_to_zig(value: &serde_json::Value) -> String {
559    match value {
560        serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
561        serde_json::Value::Bool(b) => b.to_string(),
562        serde_json::Value::Number(n) => n.to_string(),
563        serde_json::Value::Null => "null".to_string(),
564        serde_json::Value::Array(arr) => {
565            let items: Vec<String> = arr.iter().map(json_to_zig).collect();
566            format!("&.{{{}}}", items.join(", "))
567        }
568        serde_json::Value::Object(_) => {
569            let json_str = serde_json::to_string(value).unwrap_or_default();
570            format!("\"{}\"", escape_zig(&json_str))
571        }
572    }
573}