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::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions::toolchain;
14use anyhow::Result;
15use heck::{ToShoutySnakeCase, ToSnakeCase};
16use std::collections::HashSet;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22use super::streaming_assertions::{StreamingFieldResolver, is_streaming_virtual_field};
23
24pub struct ZigE2eCodegen;
26
27impl E2eCodegen for ZigE2eCodegen {
28 fn generate(
29 &self,
30 groups: &[FixtureGroup],
31 e2e_config: &E2eConfig,
32 config: &ResolvedCrateConfig,
33 _type_defs: &[alef_core::ir::TypeDef],
34 ) -> Result<Vec<GeneratedFile>> {
35 let lang = self.language_name();
36 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
37
38 let mut files = Vec::new();
39
40 let call = &e2e_config.call;
42 let overrides = call.overrides.get(lang);
43 let _module_path = overrides
44 .and_then(|o| o.module.as_ref())
45 .cloned()
46 .unwrap_or_else(|| call.module.clone());
47 let function_name = overrides
48 .and_then(|o| o.function.as_ref())
49 .cloned()
50 .unwrap_or_else(|| call.function.clone());
51 let result_var = &call.result_var;
52
53 let zig_pkg = e2e_config.resolve_package("zig");
55 let pkg_path = zig_pkg
56 .as_ref()
57 .and_then(|p| p.path.as_ref())
58 .cloned()
59 .unwrap_or_else(|| "../../packages/zig".to_string());
60 let pkg_name = zig_pkg
61 .as_ref()
62 .and_then(|p| p.name.as_ref())
63 .cloned()
64 .unwrap_or_else(|| config.name.to_snake_case());
65
66 files.push(GeneratedFile {
68 path: output_base.join("build.zig.zon"),
69 content: render_build_zig_zon(&pkg_name, &pkg_path, e2e_config.dep_mode),
70 generated_header: false,
71 });
72
73 let module_name = config.zig_module_name();
75 let ffi_prefix = config.ffi_prefix();
76
77 let field_resolver = FieldResolver::new(
79 &e2e_config.fields,
80 &e2e_config.fields_optional,
81 &e2e_config.result_fields,
82 &e2e_config.fields_array,
83 &e2e_config.fields_method_calls,
84 );
85
86 let mut test_filenames: Vec<String> = Vec::new();
88 for group in groups {
89 let active: Vec<&Fixture> = group
90 .fixtures
91 .iter()
92 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
93 .collect();
94
95 if active.is_empty() {
96 continue;
97 }
98
99 let filename = format!("{}_test.zig", sanitize_filename(&group.category));
100 test_filenames.push(filename.clone());
101 let content = render_test_file(
102 &group.category,
103 &active,
104 e2e_config,
105 &function_name,
106 result_var,
107 &e2e_config.call.args,
108 &field_resolver,
109 &e2e_config.fields_enum,
110 &module_name,
111 &ffi_prefix,
112 );
113 files.push(GeneratedFile {
114 path: output_base.join("src").join(filename),
115 content,
116 generated_header: true,
117 });
118 }
119
120 files.insert(
122 files
123 .iter()
124 .position(|f| f.path.file_name().is_some_and(|n| n == "build.zig.zon"))
125 .unwrap_or(1),
126 GeneratedFile {
127 path: output_base.join("build.zig"),
128 content: render_build_zig(
129 &test_filenames,
130 &pkg_name,
131 &module_name,
132 &config.ffi_lib_name(),
133 &config.ffi_crate_path(),
134 &e2e_config.test_documents_relative_from(0),
135 ),
136 generated_header: false,
137 },
138 );
139
140 Ok(files)
141 }
142
143 fn language_name(&self) -> &'static str {
144 "zig"
145 }
146}
147
148fn render_build_zig_zon(pkg_name: &str, pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
153 let dep_block = match dep_mode {
154 crate::config::DependencyMode::Registry => {
155 format!(
157 r#".{{
158 .url = "https://registry.example.com/{pkg_name}/v0.1.0.tar.gz",
159 .hash = "0000000000000000000000000000000000000000000000000000000000000000",
160 }}"#
161 )
162 }
163 crate::config::DependencyMode::Local => {
164 format!(r#".{{ .path = "{pkg_path}" }}"#)
165 }
166 };
167
168 let min_zig = toolchain::MIN_ZIG_VERSION;
169 let name_bytes: &[u8] = b"e2e_zig";
171 let mut crc: u32 = 0xffff_ffff;
172 for byte in name_bytes {
173 crc ^= *byte as u32;
174 for _ in 0..8 {
175 let mask = (crc & 1).wrapping_neg();
176 crc = (crc >> 1) ^ (0xedb8_8320 & mask);
177 }
178 }
179 let name_crc: u32 = !crc;
180 let mut id: u32 = 0x811c_9dc5;
181 for byte in name_bytes {
182 id ^= *byte as u32;
183 id = id.wrapping_mul(0x0100_0193);
184 }
185 if id == 0 || id == 0xffff_ffff {
186 id = 0x1;
187 }
188 let fingerprint: u64 = ((name_crc as u64) << 32) | (id as u64);
189 format!(
190 r#".{{
191 .name = .e2e_zig,
192 .version = "0.1.0",
193 .fingerprint = 0x{fingerprint:016x},
194 .minimum_zig_version = "{min_zig}",
195 .dependencies = .{{
196 .{pkg_name} = {dep_block},
197 }},
198 .paths = .{{
199 "build.zig",
200 "build.zig.zon",
201 "src",
202 }},
203}}
204"#
205 )
206}
207
208fn render_build_zig(
209 test_filenames: &[String],
210 pkg_name: &str,
211 module_name: &str,
212 ffi_lib_name: &str,
213 ffi_crate_path: &str,
214 test_documents_path: &str,
215) -> String {
216 if test_filenames.is_empty() {
217 return r#"const std = @import("std");
218
219pub fn build(b: *std.Build) void {
220 const target = b.standardTargetOptions(.{});
221 const optimize = b.standardOptimizeOption(.{});
222
223 const test_step = b.step("test", "Run tests");
224}
225"#
226 .to_string();
227 }
228
229 let mut content = String::from("const std = @import(\"std\");\n\npub fn build(b: *std.Build) void {\n");
239 content.push_str(" const target = b.standardTargetOptions(.{});\n");
240 content.push_str(" const optimize = b.standardOptimizeOption(.{});\n");
241 content.push_str(" const test_step = b.step(\"test\", \"Run tests\");\n");
242 let _ = writeln!(
243 content,
244 " const ffi_path = b.option([]const u8, \"ffi_path\", \"Path to directory containing lib{ffi_lib_name}\") orelse \"../../target/debug\";"
245 );
246 let _ = writeln!(
247 content,
248 " const ffi_include = b.option([]const u8, \"ffi_include_path\", \"Path to directory containing FFI header\") orelse \"{ffi_crate_path}/include\";"
249 );
250 let _ = writeln!(content);
251 let _ = writeln!(
252 content,
253 " const {module_name}_module = b.addModule(\"{module_name}\", .{{"
254 );
255 let _ = writeln!(
256 content,
257 " .root_source_file = b.path(\"../../packages/zig/src/{pkg_name}.zig\"),"
258 );
259 content.push_str(" .target = target,\n");
260 content.push_str(" .optimize = optimize,\n");
261 content.push_str(" .link_libc = true,\n");
265 content.push_str(" });\n");
266 let _ = writeln!(
267 content,
268 " {module_name}_module.addLibraryPath(.{{ .cwd_relative = ffi_path }});"
269 );
270 let _ = writeln!(
271 content,
272 " {module_name}_module.addIncludePath(.{{ .cwd_relative = ffi_include }});"
273 );
274 let _ = writeln!(
275 content,
276 " {module_name}_module.linkSystemLibrary(\"{ffi_lib_name}\", .{{}});"
277 );
278 let _ = writeln!(content);
279
280 for filename in test_filenames {
281 let test_name = filename.trim_end_matches("_test.zig");
283 content.push_str(&format!(" const {test_name}_module = b.createModule(.{{\n"));
284 content.push_str(&format!(" .root_source_file = b.path(\"src/{filename}\"),\n"));
285 content.push_str(" .target = target,\n");
286 content.push_str(" .optimize = optimize,\n");
287 content.push_str(" .link_libc = true,\n");
291 content.push_str(" });\n");
292 content.push_str(&format!(
293 " {test_name}_module.addImport(\"{module_name}\", {module_name}_module);\n"
294 ));
295 content.push_str(&format!(" const {test_name}_tests = b.addTest(.{{\n"));
311 content.push_str(&format!(" .name = \"{test_name}_test\",\n"));
312 content.push_str(&format!(" .root_module = {test_name}_module,\n"));
313 content.push_str(" .use_llvm = true,\n");
314 content.push_str(" });\n");
315 content.push_str(&format!(" b.installArtifact({test_name}_tests);\n"));
323 content.push_str(&format!(
324 " const {test_name}_run = b.addRunArtifact({test_name}_tests);\n"
325 ));
326 content.push_str(&format!(
327 " {test_name}_run.setCwd(b.path(\"{test_documents_path}\"));\n"
328 ));
329 content.push_str(&format!(" test_step.dependOn(&{test_name}_run.step);\n\n"));
330 }
331
332 content.push_str("}\n");
333 content
334}
335
336struct ZigTestClientRenderer;
344
345impl client::TestClientRenderer for ZigTestClientRenderer {
346 fn language_name(&self) -> &'static str {
347 "zig"
348 }
349
350 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
351 if let Some(reason) = skip_reason {
352 let _ = writeln!(out, "test \"{fn_name}\" {{");
353 let _ = writeln!(out, " // {description}");
354 let _ = writeln!(out, " // skipped: {reason}");
355 let _ = writeln!(out, " return error.SkipZigTest;");
356 } else {
357 let _ = writeln!(out, "test \"{fn_name}\" {{");
358 let _ = writeln!(out, " // {description}");
359 }
360 }
361
362 fn render_test_close(&self, out: &mut String) {
363 let _ = writeln!(out, "}}");
364 }
365
366 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
367 let method = ctx.method.to_uppercase();
368 let fixture_id = ctx.path.trim_start_matches("/fixtures/");
369
370 let _ = writeln!(out, " var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
371 let _ = writeln!(out, " defer _ = gpa.deinit();");
372 let _ = writeln!(out, " const allocator = gpa.allocator();");
373
374 let _ = writeln!(out, " var url_buf: [512]u8 = undefined;");
375 let _ = writeln!(
376 out,
377 " const url = try std.fmt.bufPrint(&url_buf, \"{{s}}/fixtures/{fixture_id}\", .{{if (std.c.getenv(\"MOCK_SERVER_URL\")) |v| std.mem.span(v) else \"http://localhost:8080\"}});"
378 );
379
380 if !ctx.headers.is_empty() {
382 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
383 header_pairs.sort_by_key(|(k, _)| k.as_str());
384 let _ = writeln!(out, " const headers = [_]std.http.Header{{");
385 for (k, v) in &header_pairs {
386 let ek = escape_zig(k);
387 let ev = escape_zig(v);
388 let _ = writeln!(out, " .{{ .name = \"{ek}\", .value = \"{ev}\" }},");
389 }
390 let _ = writeln!(out, " }};");
391 }
392
393 if let Some(body) = ctx.body {
395 let json_str = serde_json::to_string(body).unwrap_or_default();
396 let escaped = escape_zig(&json_str);
397 let _ = writeln!(out, " const body_bytes: []const u8 = \"{escaped}\";");
398 }
399
400 let headers_arg = if ctx.headers.is_empty() { "&.{}" } else { "&headers" };
401 let has_body = ctx.body.is_some();
402
403 let _ = writeln!(
404 out,
405 " var http_client = std.http.Client{{ .allocator = allocator }};"
406 );
407 let _ = writeln!(out, " defer http_client.deinit();");
408 let _ = writeln!(out, " var response_body = std.ArrayList(u8).init(allocator);");
409 let _ = writeln!(out, " defer response_body.deinit();");
410
411 let method_zig = match method.as_str() {
412 "GET" => ".GET",
413 "POST" => ".POST",
414 "PUT" => ".PUT",
415 "DELETE" => ".DELETE",
416 "PATCH" => ".PATCH",
417 "HEAD" => ".HEAD",
418 "OPTIONS" => ".OPTIONS",
419 _ => ".GET",
420 };
421
422 let payload_field = if has_body { ", .payload = body_bytes" } else { "" };
423 let _ = writeln!(
424 out,
425 " const {rv} = try http_client.fetch(.{{ .location = .{{ .url = url }}, .method = {method_zig}, .extra_headers = {headers_arg}{payload_field}, .response_storage = .{{ .dynamic = &response_body }} }});",
426 rv = ctx.response_var,
427 );
428 }
429
430 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
431 let _ = writeln!(
432 out,
433 " try testing.expectEqual(@as(u10, {status}), @intFromEnum({response_var}.status));"
434 );
435 }
436
437 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
438 let ename = escape_zig(&name.to_lowercase());
439 match expected {
440 "<<present>>" => {
441 let _ = writeln!(
442 out,
443 " // assert header '{ename}' is present (header inspection not yet implemented)"
444 );
445 }
446 "<<absent>>" => {
447 let _ = writeln!(
448 out,
449 " // assert header '{ename}' is absent (header inspection not yet implemented)"
450 );
451 }
452 "<<uuid>>" => {
453 let _ = writeln!(
454 out,
455 " // assert header '{ename}' matches UUID pattern (header inspection not yet implemented)"
456 );
457 }
458 exact => {
459 let evalue = escape_zig(exact);
460 let _ = writeln!(
461 out,
462 " // assert header '{ename}' == \"{evalue}\" (header inspection not yet implemented)"
463 );
464 }
465 }
466 }
467
468 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
469 let json_str = serde_json::to_string(expected).unwrap_or_default();
470 let escaped = escape_zig(&json_str);
471 let _ = writeln!(
472 out,
473 " try testing.expectEqualStrings(\"{escaped}\", response_body.items);"
474 );
475 }
476
477 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
478 if let Some(obj) = expected.as_object() {
479 for (key, val) in obj {
480 let ekey = escape_zig(key);
481 let eval = escape_zig(&serde_json::to_string(val).unwrap_or_default());
482 let _ = writeln!(
483 out,
484 " // assert body contains field \"{ekey}\" = \"{eval}\" (partial JSON not yet implemented)"
485 );
486 }
487 }
488 }
489
490 fn render_assert_validation_errors(
491 &self,
492 out: &mut String,
493 _response_var: &str,
494 errors: &[crate::fixture::ValidationErrorExpectation],
495 ) {
496 for ve in errors {
497 let loc = ve.loc.join(".");
498 let escaped_loc = escape_zig(&loc);
499 let escaped_msg = escape_zig(&ve.msg);
500 let _ = writeln!(
501 out,
502 " // assert validation error at \"{escaped_loc}\": \"{escaped_msg}\" (not yet implemented)"
503 );
504 }
505 }
506}
507
508fn render_http_test_case(out: &mut String, fixture: &Fixture) {
513 client::http_call::render_http_test(out, &ZigTestClientRenderer, fixture);
514}
515
516#[allow(clippy::too_many_arguments)]
521fn render_test_file(
522 category: &str,
523 fixtures: &[&Fixture],
524 e2e_config: &E2eConfig,
525 function_name: &str,
526 result_var: &str,
527 args: &[crate::config::ArgMapping],
528 field_resolver: &FieldResolver,
529 enum_fields: &HashSet<String>,
530 module_name: &str,
531 ffi_prefix: &str,
532) -> String {
533 let mut out = String::new();
534 out.push_str(&hash::header(CommentStyle::DoubleSlash));
535 let _ = writeln!(out, "const std = @import(\"std\");");
536 let _ = writeln!(out, "const testing = std.testing;");
537 let _ = writeln!(out, "const {module_name} = @import(\"{module_name}\");");
538 let _ = writeln!(out);
539
540 let _ = writeln!(out, "// E2e tests for category: {category}");
541 let _ = writeln!(out);
542
543 for fixture in fixtures {
544 if fixture.http.is_some() {
545 render_http_test_case(&mut out, fixture);
546 } else {
547 render_test_fn(
548 &mut out,
549 fixture,
550 e2e_config,
551 function_name,
552 result_var,
553 args,
554 field_resolver,
555 enum_fields,
556 module_name,
557 ffi_prefix,
558 );
559 }
560 let _ = writeln!(out);
561 }
562
563 out
564}
565
566#[allow(clippy::too_many_arguments)]
567fn render_test_fn(
568 out: &mut String,
569 fixture: &Fixture,
570 e2e_config: &E2eConfig,
571 _function_name: &str,
572 _result_var: &str,
573 _args: &[crate::config::ArgMapping],
574 field_resolver: &FieldResolver,
575 enum_fields: &HashSet<String>,
576 module_name: &str,
577 ffi_prefix: &str,
578) {
579 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
581 let lang = "zig";
582 let call_overrides = call_config.overrides.get(lang);
583 let function_name = call_overrides
584 .and_then(|o| o.function.as_ref())
585 .cloned()
586 .unwrap_or_else(|| call_config.function.clone());
587 let result_var = &call_config.result_var;
588 let args = &call_config.args;
589 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
594 e2e_config
595 .call
596 .overrides
597 .get(lang)
598 .and_then(|o| o.client_factory.as_deref())
599 });
600
601 let call_result_is_bytes = call_config.result_is_bytes || call_config.overrides.values().any(|o| o.result_is_bytes);
618 let result_is_json_struct =
619 !call_result_is_bytes && (call_overrides.is_some_and(|o| o.result_is_json_struct) || client_factory.is_some());
620
621 let result_is_option = call_overrides.is_some_and(|o| o.result_is_option) || call_config.result_is_option;
626
627 let test_name = fixture.id.to_snake_case();
628 let description = &fixture.description;
629 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
630
631 let (setup_lines, args_str, setup_needs_gpa) = build_args_and_setup(&fixture.input, args, &fixture.id, module_name);
632 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
638 let args_str = if extra_args.is_empty() {
639 args_str
640 } else if args_str.is_empty() {
641 extra_args.join(", ")
642 } else {
643 format!("{args_str}, {}", extra_args.join(", "))
644 };
645
646 let any_happy_emits_code = fixture
649 .assertions
650 .iter()
651 .any(|a| assertion_emits_code(a, field_resolver));
652 let any_non_error_emits_code = fixture
653 .assertions
654 .iter()
655 .filter(|a| a.assertion_type != "error")
656 .any(|a| assertion_emits_code(a, field_resolver));
657
658 let has_streaming_virtual_assertions = fixture.assertions.iter().any(|a| {
660 a.field
661 .as_ref()
662 .is_some_and(|f| !f.is_empty() && is_streaming_virtual_field(f))
663 });
664 let is_stream_fn = function_name.contains("stream");
665 let uses_streaming_virtual_path =
666 result_is_json_struct && has_streaming_virtual_assertions && is_stream_fn && client_factory.is_some();
667 let streaming_path_has_non_streaming = uses_streaming_virtual_path
669 && fixture.assertions.iter().any(|a| {
670 !a.field
671 .as_ref()
672 .is_some_and(|f| !f.is_empty() && is_streaming_virtual_field(f))
673 && !matches!(a.assertion_type.as_str(), "not_error" | "error")
674 && a.field
675 .as_ref()
676 .is_some_and(|f| !f.is_empty() && field_resolver.is_valid_for_result(f))
677 });
678
679 let _ = writeln!(out, "test \"{test_name}\" {{");
680 let _ = writeln!(out, " // {description}");
681
682 if let Some(visitor_spec) = &fixture.visitor {
687 let html = fixture.input.get("html").and_then(|v| v.as_str()).unwrap_or_default();
688 let options_value = fixture.input.get("options").cloned();
689 emit_visitor_test_body(
690 out,
691 &fixture.id,
692 html,
693 options_value.as_ref(),
694 visitor_spec,
695 module_name,
696 &fixture.assertions,
697 expects_error,
698 field_resolver,
699 );
700 let _ = writeln!(out, "}}");
701 let _ = writeln!(out);
702 return;
703 }
704
705 let needs_gpa = setup_needs_gpa
713 || streaming_path_has_non_streaming
714 || (!uses_streaming_virtual_path && result_is_json_struct && !expects_error && any_happy_emits_code)
715 || (!uses_streaming_virtual_path && result_is_json_struct && expects_error && any_non_error_emits_code);
716 if needs_gpa {
717 let _ = writeln!(out, " var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
718 let _ = writeln!(out, " defer _ = gpa.deinit();");
719 let _ = writeln!(out, " const allocator = gpa.allocator();");
720 let _ = writeln!(out);
721 }
722
723 for line in &setup_lines {
724 let _ = writeln!(out, " {line}");
725 }
726
727 let call_prefix = if let Some(factory) = client_factory {
732 let fixture_id = &fixture.id;
733 let _ = writeln!(
734 out,
735 " const _mock_url = try std.fmt.allocPrintSentinel(std.heap.c_allocator, \"{{s}}/fixtures/{fixture_id}\", .{{if (std.c.getenv(\"MOCK_SERVER_URL\")) |v| std.mem.span(v) else \"http://localhost:8080\"}}, 0);"
736 );
737 let _ = writeln!(out, " defer std.heap.c_allocator.free(_mock_url);");
738 let _ = writeln!(
739 out,
740 " var _client = try {module_name}.{factory}(\"test-key\", _mock_url, null, null, null);"
741 );
742 let _ = writeln!(out, " defer _client.free();");
743 "_client".to_string()
744 } else {
745 module_name.to_string()
746 };
747
748 if expects_error {
749 if result_is_json_struct {
753 let _ = writeln!(
754 out,
755 " const _result_json = {call_prefix}.{function_name}({args_str}) catch {{"
756 );
757 } else {
758 let _ = writeln!(
759 out,
760 " const result = {call_prefix}.{function_name}({args_str}) catch {{"
761 );
762 }
763 let _ = writeln!(out, " try testing.expect(true); // Error occurred as expected");
764 let _ = writeln!(out, " return;");
765 let _ = writeln!(out, " }};");
766 let any_emits_code = fixture
770 .assertions
771 .iter()
772 .filter(|a| a.assertion_type != "error")
773 .any(|a| assertion_emits_code(a, field_resolver));
774 if result_is_json_struct && any_emits_code {
775 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
776 let _ = writeln!(
777 out,
778 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
779 );
780 let _ = writeln!(out, " defer _parsed.deinit();");
781 let _ = writeln!(out, " const {result_var} = &_parsed.value;");
782 let _ = writeln!(out, " // Perform success assertions if any");
783 for assertion in &fixture.assertions {
784 if assertion.assertion_type != "error" {
785 render_json_assertion(out, assertion, result_var, field_resolver);
786 }
787 }
788 } else if result_is_json_struct {
789 let _ = writeln!(out, " _ = _result_json;");
790 } else if any_emits_code {
791 let _ = writeln!(out, " // Perform success assertions if any");
792 for assertion in &fixture.assertions {
793 if assertion.assertion_type != "error" {
794 render_assertion(
795 out,
796 assertion,
797 result_var,
798 field_resolver,
799 enum_fields,
800 result_is_option,
801 );
802 }
803 }
804 } else {
805 let _ = writeln!(out, " _ = result;");
806 }
807 } else if fixture.assertions.is_empty() {
808 if result_is_json_struct {
810 let _ = writeln!(
811 out,
812 " const _result_json = try {call_prefix}.{function_name}({args_str});"
813 );
814 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
815 } else {
816 let _ = writeln!(out, " _ = try {call_prefix}.{function_name}({args_str});");
817 }
818 } else {
819 let any_emits_code = fixture
823 .assertions
824 .iter()
825 .any(|a| assertion_emits_code(a, field_resolver));
826 if call_result_is_bytes && client_factory.is_some() {
827 let _ = writeln!(
830 out,
831 " const _result_json = try {call_prefix}.{function_name}({args_str});"
832 );
833 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
834 let has_bytes_assertions = fixture
835 .assertions
836 .iter()
837 .any(|a| matches!(a.assertion_type.as_str(), "not_empty" | "is_empty"));
838 if has_bytes_assertions {
839 for assertion in &fixture.assertions {
840 match assertion.assertion_type.as_str() {
841 "not_empty" => {
842 let _ = writeln!(out, " try testing.expect(_result_json.len > 0);");
843 }
844 "is_empty" => {
845 let _ = writeln!(out, " try testing.expectEqual(@as(usize, 0), _result_json.len);");
846 }
847 "not_error" | "error" => {}
848 _ => {
849 let atype = &assertion.assertion_type;
850 let _ = writeln!(
851 out,
852 " // bytes result: assertion '{atype}' not implemented for zig bytes"
853 );
854 }
855 }
856 }
857 }
858 } else if result_is_json_struct {
859 if uses_streaming_virtual_path {
863 let request_from_json = format!("{ffi_prefix}_chat_completion_request_from_json");
864 let request_free = format!("{ffi_prefix}_chat_completion_request_free");
865 let stream_start = format!("{ffi_prefix}_default_client_chat_stream_start");
866 let stream_free = format!("{ffi_prefix}_default_client_chat_stream_free");
867 let client_c_type = format!("{}DefaultClient", ffi_prefix.to_shouty_snake_case());
868
869 let _ = writeln!(
872 out,
873 " const _req_z = try std.heap.c_allocator.dupeZ(u8, {args_str});"
874 );
875 let _ = writeln!(out, " defer std.heap.c_allocator.free(_req_z);");
876 let _ = writeln!(
877 out,
878 " const _req_handle = {module_name}.c.{request_from_json}(_req_z.ptr);"
879 );
880 let _ = writeln!(out, " defer {module_name}.c.{request_free}(_req_handle);");
881 let _ = writeln!(
882 out,
883 " const _stream_handle = {module_name}.c.{stream_start}(@as(*{module_name}.c.{client_c_type}, @ptrCast(_client._handle)), _req_handle);"
884 );
885 let _ = writeln!(out, " if (_stream_handle == null) return error.StreamStartFailed;");
886 let _ = writeln!(out, " defer {module_name}.c.{stream_free}(_stream_handle);");
887 let snip =
889 StreamingFieldResolver::collect_snippet_zig("_stream_handle", "chunks", module_name, ffi_prefix);
890 out.push_str(" ");
891 out.push_str(&snip);
892 out.push('\n');
893 if streaming_path_has_non_streaming {
896 let _ = writeln!(
897 out,
898 " const _result_json = if (chunks.items.len > 0) chunks.items[chunks.items.len - 1] else &[_]u8{{}};"
899 );
900 let _ = writeln!(
901 out,
902 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
903 );
904 let _ = writeln!(out, " defer _parsed.deinit();");
905 let _ = writeln!(out, " const {result_var} = &_parsed.value;");
906 }
907 for assertion in &fixture.assertions {
908 render_json_assertion(out, assertion, result_var, field_resolver);
909 }
910 } else {
911 let _ = writeln!(
913 out,
914 " const _result_json = try {call_prefix}.{function_name}({args_str});"
915 );
916 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
917 if any_emits_code {
918 let _ = writeln!(
919 out,
920 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
921 );
922 let _ = writeln!(out, " defer _parsed.deinit();");
923 let _ = writeln!(out, " const {result_var} = &_parsed.value;");
924 for assertion in &fixture.assertions {
925 render_json_assertion(out, assertion, result_var, field_resolver);
926 }
927 }
928 }
929 } else if any_emits_code {
930 let _ = writeln!(
931 out,
932 " const {result_var} = try {call_prefix}.{function_name}({args_str});"
933 );
934 for assertion in &fixture.assertions {
935 render_assertion(
936 out,
937 assertion,
938 result_var,
939 field_resolver,
940 enum_fields,
941 result_is_option,
942 );
943 }
944 } else {
945 let _ = writeln!(out, " _ = try {call_prefix}.{function_name}({args_str});");
946 }
947 }
948
949 let _ = writeln!(out, "}}");
950}
951
952#[allow(clippy::too_many_arguments)]
958fn emit_visitor_test_body(
959 out: &mut String,
960 fixture_id: &str,
961 html: &str,
962 options_value: Option<&serde_json::Value>,
963 visitor_spec: &crate::fixture::VisitorSpec,
964 module_name: &str,
965 assertions: &[Assertion],
966 expects_error: bool,
967 field_resolver: &FieldResolver,
968) {
969 let _ = writeln!(out, " var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
971 let _ = writeln!(out, " defer _ = gpa.deinit();");
972 let _ = writeln!(out, " const allocator = gpa.allocator();");
973 let _ = writeln!(out);
974
975 let visitor_block = super::zig_visitors::build_zig_visitor(fixture_id, module_name, visitor_spec);
977 out.push_str(&visitor_block);
978
979 let _ = writeln!(
982 out,
983 " const _visitor = {module_name}.c.htm_visitor_create(&_callbacks);"
984 );
985 let _ = writeln!(out, " defer {module_name}.c.htm_visitor_free(_visitor);");
986
987 let options_json = match options_value {
991 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
992 None => "{}".to_string(),
993 };
994 let escaped_options = escape_zig(&options_json);
995 let _ = writeln!(
996 out,
997 " const _options_z = try std.heap.c_allocator.dupeZ(u8, \"{escaped_options}\");"
998 );
999 let _ = writeln!(out, " defer std.heap.c_allocator.free(_options_z);");
1000 let _ = writeln!(
1001 out,
1002 " const _options = {module_name}.c.htm_conversion_options_from_json(_options_z.ptr);"
1003 );
1004 let _ = writeln!(out, " defer {module_name}.c.htm_conversion_options_free(_options);");
1005 let _ = writeln!(
1006 out,
1007 " {module_name}.c.htm_options_set_visitor_handle(_options, _visitor);"
1008 );
1009
1010 let escaped_html = escape_zig(html);
1012 let _ = writeln!(
1013 out,
1014 " const _html_z = try std.heap.c_allocator.dupeZ(u8, \"{escaped_html}\");"
1015 );
1016 let _ = writeln!(out, " defer std.heap.c_allocator.free(_html_z);");
1017 let _ = writeln!(
1018 out,
1019 " const _result = {module_name}.c.htm_convert(_html_z.ptr, _options);"
1020 );
1021
1022 if expects_error {
1023 let _ = writeln!(
1025 out,
1026 " try testing.expect(_result == null or {module_name}.c.htm_last_error_code() != 0);"
1027 );
1028 let _ = writeln!(
1029 out,
1030 " if (_result) |r| {module_name}.c.htm_conversion_result_free(r);"
1031 );
1032 return;
1033 }
1034
1035 let _ = writeln!(out, " try testing.expect(_result != null);");
1036 let _ = writeln!(out, " defer {module_name}.c.htm_conversion_result_free(_result.?);");
1037 let _ = writeln!(
1038 out,
1039 " const _json_ptr = {module_name}.c.htm_conversion_result_to_json(_result.?);"
1040 );
1041 let _ = writeln!(out, " defer {module_name}.c.htm_free_string(_json_ptr);");
1042 let _ = writeln!(out, " const _result_json = std.mem.sliceTo(_json_ptr, 0);");
1043 let _ = writeln!(
1044 out,
1045 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
1046 );
1047 let _ = writeln!(out, " defer _parsed.deinit();");
1048 let _ = writeln!(out, " const result = &_parsed.value;");
1049
1050 for assertion in assertions {
1051 if assertion.assertion_type != "error" {
1052 render_json_assertion(out, assertion, "result", field_resolver);
1053 }
1054 }
1055}
1056
1057const FORMAT_METADATA_VARIANTS: &[&str] = &[
1075 "pdf",
1076 "docx",
1077 "excel",
1078 "email",
1079 "pptx",
1080 "archive",
1081 "image",
1082 "xml",
1083 "text",
1084 "html",
1085 "ocr",
1086 "csv",
1087 "bibtex",
1088 "citation",
1089 "fiction_book",
1090 "dbf",
1091 "jats",
1092 "epub",
1093 "pst",
1094 "code",
1095];
1096
1097fn json_path_expr(result_var: &str, field_path: &str) -> String {
1098 let segments: Vec<&str> = field_path.split('.').collect();
1099 let mut expr = result_var.to_string();
1100 let mut prev_seg: Option<&str> = None;
1101 for seg in &segments {
1102 if prev_seg == Some("format") && FORMAT_METADATA_VARIANTS.contains(seg) {
1107 prev_seg = Some(seg);
1108 continue;
1109 }
1110 if let Some(key) = seg.strip_suffix("[]") {
1114 expr = format!("{expr}.object.get(\"{key}\").?.array.items[0]");
1115 } else if let Some(bracket_pos) = seg.find('[') {
1116 if let Some(end_pos) = seg.find(']') {
1117 if end_pos > bracket_pos + 1 && end_pos == seg.len() - 1 {
1118 let key = &seg[..bracket_pos];
1119 let idx = &seg[bracket_pos + 1..end_pos];
1120 if idx.chars().all(|c| c.is_ascii_digit()) {
1121 expr = format!("{expr}.object.get(\"{key}\").?.array.items[{idx}]");
1122 prev_seg = Some(seg);
1123 continue;
1124 }
1125 expr = format!("{expr}.object.get(\"{key}\").?.object.get(\"{idx}\").?");
1131 prev_seg = Some(seg);
1132 continue;
1133 }
1134 }
1135 expr = format!("{expr}.object.get(\"{seg}\").?");
1136 } else {
1137 expr = format!("{expr}.object.get(\"{seg}\").?");
1138 }
1139 prev_seg = Some(seg);
1140 }
1141 expr
1142}
1143
1144fn render_json_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
1149 if let Some(f) = &assertion.field {
1151 if !f.is_empty() && is_streaming_virtual_field(f) {
1152 if let Some(expr) = StreamingFieldResolver::accessor(f, "zig", "chunks") {
1153 match assertion.assertion_type.as_str() {
1154 "count_min" => {
1155 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1156 let _ = writeln!(out, " try testing.expect({expr}.len >= {n});");
1157 }
1158 }
1159 "count_equals" => {
1160 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1161 let _ = writeln!(out, " try testing.expectEqual(@as(usize, {n}), {expr}.len);");
1162 }
1163 }
1164 "equals" => {
1165 if let Some(serde_json::Value::String(s)) = &assertion.value {
1166 let escaped = escape_zig(s);
1167 let _ = writeln!(out, " try testing.expectEqualStrings(\"{escaped}\", {expr});");
1168 } else if let Some(v) = &assertion.value {
1169 let zig_val = json_to_zig(v);
1170 let _ = writeln!(out, " try testing.expectEqual({zig_val}, {expr});");
1171 }
1172 }
1173 "not_empty" => {
1174 let _ = writeln!(out, " try testing.expect({expr}.len > 0);");
1175 }
1176 "is_true" => {
1177 let _ = writeln!(out, " try testing.expect({expr});");
1178 }
1179 "is_false" => {
1180 let _ = writeln!(out, " try testing.expect(!{expr});");
1181 }
1182 _ => {
1183 let atype = &assertion.assertion_type;
1184 let _ = writeln!(
1185 out,
1186 " // streaming virtual field '{f}' assertion '{atype}' not implemented for zig"
1187 );
1188 }
1189 }
1190 }
1191 return;
1192 }
1193 }
1194
1195 if let Some(f) = &assertion.field {
1202 if f == "embeddings" && !field_resolver.has_explicit_field("embeddings") {
1203 match assertion.assertion_type.as_str() {
1204 "count_min" => {
1205 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1206 let _ = writeln!(out, " try testing.expect({result_var}.array.items.len >= {n});");
1207 }
1208 return;
1209 }
1210 "count_equals" => {
1211 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1212 let _ = writeln!(
1213 out,
1214 " try testing.expectEqual(@as(usize, {n}), {result_var}.array.items.len);"
1215 );
1216 }
1217 return;
1218 }
1219 "not_empty" => {
1220 let _ = writeln!(out, " try testing.expect({result_var}.array.items.len > 0);");
1221 return;
1222 }
1223 "is_empty" => {
1224 let _ = writeln!(
1225 out,
1226 " try testing.expectEqual(@as(usize, 0), {result_var}.array.items.len);"
1227 );
1228 return;
1229 }
1230 _ => {}
1231 }
1232 }
1233 }
1234
1235 if let Some(f) = &assertion.field {
1237 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1238 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1239 return;
1240 }
1241 }
1242 if matches!(assertion.assertion_type.as_str(), "not_error" | "error") {
1244 return;
1245 }
1246
1247 let raw_field_path = assertion.field.as_deref().unwrap_or("").trim();
1248 let field_path = if raw_field_path.is_empty() {
1249 raw_field_path.to_string()
1250 } else {
1251 field_resolver.resolve(raw_field_path).to_string()
1252 };
1253 let field_path = field_path.trim();
1254
1255 let (field_path_for_expr, is_length_access) = if let Some(parent) = field_path.strip_suffix(".length") {
1257 (parent, true)
1258 } else {
1259 (field_path, false)
1260 };
1261
1262 let field_expr = if field_path_for_expr.is_empty() {
1263 result_var.to_string()
1264 } else {
1265 json_path_expr(result_var, field_path_for_expr)
1266 };
1267
1268 let zig_val = match &assertion.value {
1270 Some(serde_json::Value::String(s)) => format!("\"{}\"", escape_zig(s)),
1271 _ => String::new(),
1272 };
1273 let is_string_val = matches!(&assertion.value, Some(serde_json::Value::String(_)));
1274 let is_bool_val = matches!(&assertion.value, Some(serde_json::Value::Bool(_)));
1275 let bool_val = match &assertion.value {
1276 Some(serde_json::Value::Bool(b)) if *b => "true",
1277 _ => "false",
1278 };
1279 let is_null_val = matches!(&assertion.value, Some(serde_json::Value::Null));
1280 let n = assertion.value.as_ref().map(json_to_zig).unwrap_or_default();
1281 let has_n = assertion.value.as_ref().is_some_and(|v| v.is_number() || v.is_u64());
1282 let is_float_val = matches!(&assertion.value, Some(serde_json::Value::Number(n)) if !n.is_i64() && !n.is_u64());
1287 let values_list: Vec<String> = assertion
1288 .values
1289 .as_deref()
1290 .unwrap_or_default()
1291 .iter()
1292 .filter_map(|v| {
1293 if let serde_json::Value::String(s) = v {
1294 Some(format!("\"{}\"", escape_zig(s)))
1295 } else {
1296 None
1297 }
1298 })
1299 .collect();
1300
1301 let rendered = crate::template_env::render(
1302 "zig/json_assertion.jinja",
1303 minijinja::context! {
1304 assertion_type => assertion.assertion_type.as_str(),
1305 field_expr => field_expr,
1306 is_length_access => is_length_access,
1307 zig_val => zig_val,
1308 is_string_val => is_string_val,
1309 is_bool_val => is_bool_val,
1310 bool_val => bool_val,
1311 is_null_val => is_null_val,
1312 n => n,
1313 has_n => has_n,
1314 is_float_val => is_float_val,
1315 values_list => values_list,
1316 },
1317 );
1318 out.push_str(&rendered);
1319}
1320
1321fn assertion_emits_code(assertion: &Assertion, field_resolver: &FieldResolver) -> bool {
1324 if let Some(f) = &assertion.field {
1325 if !f.is_empty() && is_streaming_virtual_field(f) {
1326 } else if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1329 return false;
1330 }
1331 }
1332 matches!(
1333 assertion.assertion_type.as_str(),
1334 "equals"
1335 | "contains"
1336 | "contains_all"
1337 | "not_contains"
1338 | "not_empty"
1339 | "is_empty"
1340 | "starts_with"
1341 | "ends_with"
1342 | "min_length"
1343 | "max_length"
1344 | "count_min"
1345 | "count_equals"
1346 | "is_true"
1347 | "is_false"
1348 | "greater_than"
1349 | "less_than"
1350 | "greater_than_or_equal"
1351 | "less_than_or_equal"
1352 | "contains_any"
1353 )
1354}
1355
1356fn build_args_and_setup(
1361 input: &serde_json::Value,
1362 args: &[crate::config::ArgMapping],
1363 fixture_id: &str,
1364 _module_name: &str,
1365) -> (Vec<String>, String, bool) {
1366 if args.is_empty() {
1367 return (Vec::new(), String::new(), false);
1368 }
1369
1370 let mut setup_lines: Vec<String> = Vec::new();
1371 let mut parts: Vec<String> = Vec::new();
1372 let mut setup_needs_gpa = false;
1373
1374 for arg in args {
1375 if arg.arg_type == "mock_url" {
1376 let name = arg.name.clone();
1377 let id_upper = fixture_id.to_uppercase();
1378 setup_lines.push(format!(
1379 "const {name} = if (std.c.getenv(\"MOCK_SERVER_{id_upper}\")) |_pf| try std.fmt.allocPrint(allocator, \"{{s}}\", .{{std.mem.span(_pf)}}) else try std.fmt.allocPrint(allocator, \"{{s}}/fixtures/{fixture_id}\", .{{if (std.c.getenv(\"MOCK_SERVER_URL\")) |v| std.mem.span(v) else \"http://localhost:8080\"}});"
1380 ));
1381 setup_lines.push(format!("defer allocator.free({name});"));
1382 parts.push(name);
1383 setup_needs_gpa = true;
1384 continue;
1385 }
1386
1387 if arg.arg_type == "handle" {
1390 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1391 let json_str = match input.get(field) {
1392 Some(serde_json::Value::Null) | None => "null".to_string(),
1393 Some(v) => format!("\"{}\"", escape_zig(&serde_json::to_string(v).unwrap_or_default())),
1394 };
1395 parts.push(json_str);
1396 continue;
1397 }
1398
1399 if arg.name == "config" && arg.arg_type == "json_object" {
1406 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1407 let json_str = match input.get(field) {
1408 Some(serde_json::Value::Null) | None => "{}".to_string(),
1409 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1410 };
1411 parts.push(format!("\"{}\"", escape_zig(&json_str)));
1412 continue;
1413 }
1414
1415 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1416 let val = if field.is_empty() || field == "input" {
1422 Some(input)
1423 } else {
1424 input.get(field)
1425 };
1426 match val {
1427 None | Some(serde_json::Value::Null) if arg.optional => {
1428 parts.push("null".to_string());
1431 }
1432 None | Some(serde_json::Value::Null) => {
1433 let default_val = match arg.arg_type.as_str() {
1434 "string" => "\"\"".to_string(),
1435 "int" | "integer" => "0".to_string(),
1436 "float" | "number" => "0.0".to_string(),
1437 "bool" | "boolean" => "false".to_string(),
1438 "json_object" => "\"{}\"".to_string(),
1439 _ => "null".to_string(),
1440 };
1441 parts.push(default_val);
1442 }
1443 Some(v) => {
1444 if arg.arg_type == "json_object" {
1449 let json_str = serde_json::to_string(v).unwrap_or_default();
1450 parts.push(format!("\"{}\"", escape_zig(&json_str)));
1451 } else if arg.arg_type == "bytes" {
1452 if let serde_json::Value::String(path) = v {
1457 let var_name = format!("{}_bytes", arg.name);
1458 let epath = escape_zig(path);
1459 setup_lines.push(format!(
1460 "const {var_name} = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, \"{epath}\", std.heap.c_allocator, .unlimited);"
1461 ));
1462 setup_lines.push(format!("defer std.heap.c_allocator.free({var_name});"));
1463 parts.push(var_name);
1464 } else {
1465 parts.push(json_to_zig(v));
1466 }
1467 } else {
1468 parts.push(json_to_zig(v));
1469 }
1470 }
1471 }
1472 }
1473
1474 (setup_lines, parts.join(", "), setup_needs_gpa)
1475}
1476
1477fn render_assertion(
1478 out: &mut String,
1479 assertion: &Assertion,
1480 result_var: &str,
1481 field_resolver: &FieldResolver,
1482 enum_fields: &HashSet<String>,
1483 result_is_option: bool,
1484) {
1485 let bare_result_is_option = result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1488 if bare_result_is_option {
1489 match assertion.assertion_type.as_str() {
1490 "is_empty" => {
1491 let _ = writeln!(out, " try testing.expect({result_var} == null);");
1492 return;
1493 }
1494 "not_empty" | "not_error" => {
1495 let _ = writeln!(out, " try testing.expect({result_var} != null);");
1496 return;
1497 }
1498 _ => {}
1499 }
1500 }
1501 if let Some(f) = &assertion.field {
1505 if f == "embeddings" && !field_resolver.is_valid_for_result(f) {
1506 match assertion.assertion_type.as_str() {
1507 "count_min" | "count_equals" | "not_empty" | "is_empty" => {
1508 let _ = writeln!(out, " {{");
1509 let _ = writeln!(
1510 out,
1511 " var _eparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {result_var}, .{{}});"
1512 );
1513 let _ = writeln!(out, " defer _eparse.deinit();");
1514 let _ = writeln!(out, " const _embeddings_len = _eparse.value.array.items.len;");
1515 match assertion.assertion_type.as_str() {
1516 "count_min" => {
1517 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1518 let _ = writeln!(out, " try testing.expect(_embeddings_len >= {n});");
1519 }
1520 }
1521 "count_equals" => {
1522 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1523 let _ = writeln!(
1524 out,
1525 " try testing.expectEqual(@as(usize, {n}), _embeddings_len);"
1526 );
1527 }
1528 }
1529 "not_empty" => {
1530 let _ = writeln!(out, " try testing.expect(_embeddings_len > 0);");
1531 }
1532 "is_empty" => {
1533 let _ = writeln!(out, " try testing.expectEqual(@as(usize, 0), _embeddings_len);");
1534 }
1535 _ => {}
1536 }
1537 let _ = writeln!(out, " }}");
1538 return;
1539 }
1540 _ => {}
1541 }
1542 }
1543 }
1544
1545 if let Some(f) = &assertion.field {
1547 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1548 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
1549 return;
1550 }
1551 }
1552
1553 let _field_is_enum = assertion
1555 .field
1556 .as_deref()
1557 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1558
1559 let field_expr = match &assertion.field {
1560 Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
1561 _ => result_var.to_string(),
1562 };
1563
1564 match assertion.assertion_type.as_str() {
1565 "equals" => {
1566 if let Some(expected) = &assertion.value {
1567 let zig_val = json_to_zig(expected);
1568 let _ = writeln!(out, " try testing.expectEqual({zig_val}, {field_expr});");
1569 }
1570 }
1571 "contains" => {
1572 if let Some(expected) = &assertion.value {
1573 let zig_val = json_to_zig(expected);
1574 let _ = writeln!(
1575 out,
1576 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1577 );
1578 }
1579 }
1580 "contains_all" => {
1581 if let Some(values) = &assertion.values {
1582 for val in values {
1583 let zig_val = json_to_zig(val);
1584 let _ = writeln!(
1585 out,
1586 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1587 );
1588 }
1589 }
1590 }
1591 "not_contains" => {
1592 if let Some(expected) = &assertion.value {
1593 let zig_val = json_to_zig(expected);
1594 let _ = writeln!(
1595 out,
1596 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1597 );
1598 } else if let Some(values) = &assertion.values {
1599 for val in values {
1603 let zig_val = json_to_zig(val);
1604 let _ = writeln!(
1605 out,
1606 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1607 );
1608 }
1609 }
1610 }
1611 "not_empty" => {
1612 let _ = writeln!(out, " try testing.expect({field_expr}.len > 0);");
1613 }
1614 "is_empty" => {
1615 let _ = writeln!(out, " try testing.expect({field_expr}.len == 0);");
1616 }
1617 "starts_with" => {
1618 if let Some(expected) = &assertion.value {
1619 let zig_val = json_to_zig(expected);
1620 let _ = writeln!(
1621 out,
1622 " try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
1623 );
1624 }
1625 }
1626 "ends_with" => {
1627 if let Some(expected) = &assertion.value {
1628 let zig_val = json_to_zig(expected);
1629 let _ = writeln!(
1630 out,
1631 " try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
1632 );
1633 }
1634 }
1635 "min_length" => {
1636 if let Some(val) = &assertion.value {
1637 if let Some(n) = val.as_u64() {
1638 let _ = writeln!(out, " try testing.expect({field_expr}.len >= {n});");
1639 }
1640 }
1641 }
1642 "max_length" => {
1643 if let Some(val) = &assertion.value {
1644 if let Some(n) = val.as_u64() {
1645 let _ = writeln!(out, " try testing.expect({field_expr}.len <= {n});");
1646 }
1647 }
1648 }
1649 "count_min" => {
1650 if let Some(val) = &assertion.value {
1651 if let Some(n) = val.as_u64() {
1652 let _ = writeln!(out, " try testing.expect({field_expr}.len >= {n});");
1653 }
1654 }
1655 }
1656 "count_equals" => {
1657 if let Some(val) = &assertion.value {
1658 if let Some(n) = val.as_u64() {
1659 let has_field = assertion.field.as_deref().is_some_and(|f| !f.is_empty());
1663 if has_field {
1664 let _ = writeln!(out, " try testing.expectEqual({n}, {field_expr}.len);");
1665 } else {
1666 let _ = writeln!(out, " {{");
1667 let _ = writeln!(
1668 out,
1669 " var _cparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {field_expr}, .{{}});"
1670 );
1671 let _ = writeln!(out, " defer _cparse.deinit();");
1672 let _ = writeln!(
1673 out,
1674 " try testing.expectEqual({n}, _cparse.value.array.items.len);"
1675 );
1676 let _ = writeln!(out, " }}");
1677 }
1678 }
1679 }
1680 }
1681 "is_true" => {
1682 let _ = writeln!(out, " try testing.expect({field_expr});");
1683 }
1684 "is_false" => {
1685 let _ = writeln!(out, " try testing.expect(!{field_expr});");
1686 }
1687 "not_error" => {
1688 }
1690 "error" => {
1691 }
1693 "greater_than" => {
1694 if let Some(val) = &assertion.value {
1695 let zig_val = json_to_zig(val);
1696 let _ = writeln!(out, " try testing.expect({field_expr} > {zig_val});");
1697 }
1698 }
1699 "less_than" => {
1700 if let Some(val) = &assertion.value {
1701 let zig_val = json_to_zig(val);
1702 let _ = writeln!(out, " try testing.expect({field_expr} < {zig_val});");
1703 }
1704 }
1705 "greater_than_or_equal" => {
1706 if let Some(val) = &assertion.value {
1707 let zig_val = json_to_zig(val);
1708 let _ = writeln!(out, " try testing.expect({field_expr} >= {zig_val});");
1709 }
1710 }
1711 "less_than_or_equal" => {
1712 if let Some(val) = &assertion.value {
1713 let zig_val = json_to_zig(val);
1714 let _ = writeln!(out, " try testing.expect({field_expr} <= {zig_val});");
1715 }
1716 }
1717 "contains_any" => {
1718 if let Some(values) = &assertion.values {
1720 let string_values: Vec<String> = values
1721 .iter()
1722 .filter_map(|v| {
1723 if let serde_json::Value::String(s) = v {
1724 Some(format!(
1725 "std.mem.indexOf(u8, {field_expr}, \"{}\") != null",
1726 escape_zig(s)
1727 ))
1728 } else {
1729 None
1730 }
1731 })
1732 .collect();
1733 if !string_values.is_empty() {
1734 let condition = string_values.join(" or\n ");
1735 let _ = writeln!(out, " try testing.expect(\n {condition}\n );");
1736 }
1737 }
1738 }
1739 "matches_regex" => {
1740 let _ = writeln!(out, " // regex match not yet implemented for Zig");
1741 }
1742 "method_result" => {
1743 let _ = writeln!(out, " // method_result assertions not yet implemented for Zig");
1744 }
1745 other => {
1746 panic!("Zig e2e generator: unsupported assertion type: {other}");
1747 }
1748 }
1749}
1750
1751fn json_to_zig(value: &serde_json::Value) -> String {
1753 match value {
1754 serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
1755 serde_json::Value::Bool(b) => b.to_string(),
1756 serde_json::Value::Number(n) => n.to_string(),
1757 serde_json::Value::Null => "null".to_string(),
1758 serde_json::Value::Array(arr) => {
1759 let items: Vec<String> = arr.iter().map(json_to_zig).collect();
1760 format!("&.{{{}}}", items.join(", "))
1761 }
1762 serde_json::Value::Object(_) => {
1763 let json_str = serde_json::to_string(value).unwrap_or_default();
1764 format!("\"{}\"", escape_zig(&json_str))
1765 }
1766 }
1767}