1use 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
22pub 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 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 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 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 let module_name = alef_config.zig_module_name();
72
73 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 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 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
135fn 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 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 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 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 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 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 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 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
348fn 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 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 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 }
519 "error" => {
520 }
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
569fn 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}