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 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
336fn 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 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 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 }
507 "error" => {
508 }
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
557fn 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}