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 _enums: &[alef_core::ir::EnumDef],
35 ) -> Result<Vec<GeneratedFile>> {
36 let lang = self.language_name();
37 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
38
39 let mut files = Vec::new();
40
41 let call = &e2e_config.call;
43 let overrides = call.overrides.get(lang);
44 let _module_path = overrides
45 .and_then(|o| o.module.as_ref())
46 .cloned()
47 .unwrap_or_else(|| call.module.clone());
48 let function_name = overrides
49 .and_then(|o| o.function.as_ref())
50 .cloned()
51 .unwrap_or_else(|| call.function.clone());
52 let result_var = &call.result_var;
53
54 let zig_pkg = e2e_config.resolve_package("zig");
56 let pkg_path = zig_pkg
57 .as_ref()
58 .and_then(|p| p.path.as_ref())
59 .cloned()
60 .unwrap_or_else(|| "../../packages/zig".to_string());
61 let pkg_name = zig_pkg
62 .as_ref()
63 .and_then(|p| p.name.as_ref())
64 .cloned()
65 .unwrap_or_else(|| config.name.to_snake_case());
66
67 files.push(GeneratedFile {
69 path: output_base.join("build.zig.zon"),
70 content: render_build_zig_zon(&pkg_name, &pkg_path, e2e_config.dep_mode),
71 generated_header: false,
72 });
73
74 let module_name = config.zig_module_name();
76 let ffi_prefix = config.ffi_prefix();
77
78 let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
88 let cc = e2e_config.resolve_call_for_fixture(
89 f.call.as_deref(),
90 &f.id,
91 &f.resolved_category(),
92 &f.tags,
93 &f.input,
94 );
95 cc.args
96 .iter()
97 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
98 });
99
100 let zig_languages = config.zig.as_ref().and_then(|z| {
107 if z.languages.is_empty() {
108 None
109 } else {
110 Some(z.languages.clone())
111 }
112 });
113
114 let mut test_filenames: Vec<String> = Vec::new();
123 for group in groups {
124 let active: Vec<&Fixture> = group
125 .fixtures
126 .iter()
127 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
128 .filter(|f| {
129 if let Some(ref zig_langs) = zig_languages {
135 let fix_lang = f.input.get("language").and_then(|v| v.as_str()).or_else(|| {
136 f.input
137 .get("config")
138 .and_then(|c| c.get("language"))
139 .and_then(|v| v.as_str())
140 });
141 if let Some(fix_lang) = fix_lang
142 && !zig_langs.iter().any(|l| l == fix_lang)
143 {
144 return false;
145 }
146 }
147 true
148 })
149 .filter(|f| {
150 let cc = e2e_config.resolve_call_for_fixture(
151 f.call.as_deref(),
152 &f.id,
153 &f.resolved_category(),
154 &f.tags,
155 &f.input,
156 );
157 cc.streaming != Some(true)
158 })
159 .collect();
160
161 if active.is_empty() {
162 continue;
163 }
164
165 let filename = format!("{}_test.zig", sanitize_filename(&group.category));
166 test_filenames.push(filename.clone());
167 let content = render_test_file(
168 &group.category,
169 &active,
170 e2e_config,
171 &function_name,
172 result_var,
173 &e2e_config.call.args,
174 &module_name,
175 &ffi_prefix,
176 );
177 files.push(GeneratedFile {
178 path: output_base.join("src").join(filename),
179 content,
180 generated_header: true,
181 });
182 }
183
184 files.insert(
186 files
187 .iter()
188 .position(|f| f.path.file_name().is_some_and(|n| n == "build.zig.zon"))
189 .unwrap_or(1),
190 GeneratedFile {
191 path: output_base.join("build.zig"),
192 content: render_build_zig(
193 &test_filenames,
194 &pkg_name,
195 &module_name,
196 &config.ffi_lib_name(),
197 &config.ffi_crate_path(),
198 has_file_fixtures,
199 &e2e_config.test_documents_relative_from(0),
200 ),
201 generated_header: false,
202 },
203 );
204
205 Ok(files)
206 }
207
208 fn language_name(&self) -> &'static str {
209 "zig"
210 }
211}
212
213fn render_build_zig_zon(pkg_name: &str, pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
218 let dep_block = match dep_mode {
219 crate::config::DependencyMode::Registry => {
220 format!(
222 r#".{{
223 .url = "https://registry.example.com/{pkg_name}/v0.1.0.tar.gz",
224 .hash = "0000000000000000000000000000000000000000000000000000000000000000",
225 }}"#
226 )
227 }
228 crate::config::DependencyMode::Local => {
229 format!(r#".{{ .path = "{pkg_path}" }}"#)
230 }
231 };
232
233 let min_zig = toolchain::MIN_ZIG_VERSION;
234 let name_bytes: &[u8] = b"e2e_zig";
236 let mut crc: u32 = 0xffff_ffff;
237 for byte in name_bytes {
238 crc ^= *byte as u32;
239 for _ in 0..8 {
240 let mask = (crc & 1).wrapping_neg();
241 crc = (crc >> 1) ^ (0xedb8_8320 & mask);
242 }
243 }
244 let name_crc: u32 = !crc;
245 let mut id: u32 = 0x811c_9dc5;
246 for byte in name_bytes {
247 id ^= *byte as u32;
248 id = id.wrapping_mul(0x0100_0193);
249 }
250 if id == 0 || id == 0xffff_ffff {
251 id = 0x1;
252 }
253 let fingerprint: u64 = ((name_crc as u64) << 32) | (id as u64);
254 format!(
255 r#".{{
256 .name = .e2e_zig,
257 .version = "0.1.0",
258 .fingerprint = 0x{fingerprint:016x},
259 .minimum_zig_version = "{min_zig}",
260 .dependencies = .{{
261 .{pkg_name} = {dep_block},
262 }},
263 .paths = .{{
264 "build.zig",
265 "build.zig.zon",
266 "src",
267 }},
268}}
269"#
270 )
271}
272
273fn render_build_zig(
274 test_filenames: &[String],
275 pkg_name: &str,
276 module_name: &str,
277 ffi_lib_name: &str,
278 ffi_crate_path: &str,
279 has_file_fixtures: bool,
280 test_documents_path: &str,
281) -> String {
282 if test_filenames.is_empty() {
283 return r#"const std = @import("std");
284
285pub fn build(b: *std.Build) void {
286 const target = b.standardTargetOptions(.{});
287 const optimize = b.standardOptimizeOption(.{});
288
289 const test_step = b.step("test", "Run tests");
290}
291"#
292 .to_string();
293 }
294
295 let mut content = String::from("const std = @import(\"std\");\n\npub fn build(b: *std.Build) void {\n");
305 content.push_str(" const target = b.standardTargetOptions(.{});\n");
306 content.push_str(" const optimize = b.standardOptimizeOption(.{});\n");
307 content.push_str(" const test_step = b.step(\"test\", \"Run tests\");\n");
308 let _ = writeln!(
309 content,
310 " const ffi_path = b.option([]const u8, \"ffi_path\", \"Path to directory containing lib{ffi_lib_name}\") orelse \"../../target/release\";"
311 );
312 let _ = writeln!(
313 content,
314 " const ffi_include = b.option([]const u8, \"ffi_include_path\", \"Path to directory containing FFI header\") orelse \"{ffi_crate_path}/include\";"
315 );
316 let _ = writeln!(content);
317 let _ = writeln!(
318 content,
319 " const {module_name}_module = b.addModule(\"{module_name}\", .{{"
320 );
321 let _ = writeln!(
322 content,
323 " .root_source_file = b.path(\"../../packages/zig/src/{pkg_name}.zig\"),"
324 );
325 content.push_str(" .target = target,\n");
326 content.push_str(" .optimize = optimize,\n");
327 content.push_str(" .link_libc = true,\n");
331 content.push_str(" });\n");
332 let _ = writeln!(
333 content,
334 " {module_name}_module.addLibraryPath(.{{ .cwd_relative = ffi_path }});"
335 );
336 let _ = writeln!(
337 content,
338 " {module_name}_module.addIncludePath(.{{ .cwd_relative = ffi_include }});"
339 );
340 let _ = writeln!(
341 content,
342 " {module_name}_module.linkSystemLibrary(\"{ffi_lib_name}\", .{{}});"
343 );
344 let _ = writeln!(content);
345
346 for filename in test_filenames {
347 let test_name = filename.trim_end_matches("_test.zig");
349 content.push_str(&format!(" const {test_name}_module = b.createModule(.{{\n"));
350 content.push_str(&format!(" .root_source_file = b.path(\"src/{filename}\"),\n"));
351 content.push_str(" .target = target,\n");
352 content.push_str(" .optimize = optimize,\n");
353 content.push_str(" .link_libc = true,\n");
357 content.push_str(" });\n");
358 content.push_str(&format!(
359 " {test_name}_module.addImport(\"{module_name}\", {module_name}_module);\n"
360 ));
361 content.push_str(&format!(" const {test_name}_tests = b.addTest(.{{\n"));
377 content.push_str(&format!(" .name = \"{test_name}_test\",\n"));
378 content.push_str(&format!(" .root_module = {test_name}_module,\n"));
379 content.push_str(" .use_llvm = true,\n");
380 content.push_str(" });\n");
381 content.push_str(&format!(
398 " const {test_name}_run = b.addRunArtifact({test_name}_tests);\n"
399 ));
400 if has_file_fixtures {
401 content.push_str(&format!(
402 " {test_name}_run.setCwd(b.path(\"{test_documents_path}\"));\n"
403 ));
404 }
405 content.push_str(&format!(" test_step.dependOn(&{test_name}_run.step);\n\n"));
406 }
407
408 content.push_str("}\n");
409 content
410}
411
412struct ZigTestClientRenderer;
420
421impl client::TestClientRenderer for ZigTestClientRenderer {
422 fn language_name(&self) -> &'static str {
423 "zig"
424 }
425
426 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
427 if let Some(reason) = skip_reason {
428 let _ = writeln!(out, "test \"{fn_name}\" {{");
429 let _ = writeln!(out, " // {description}");
430 let _ = writeln!(out, " // skipped: {reason}");
431 let _ = writeln!(out, " return error.SkipZigTest;");
432 } else {
433 let _ = writeln!(out, "test \"{fn_name}\" {{");
434 let _ = writeln!(out, " // {description}");
435 }
436 }
437
438 fn render_test_close(&self, out: &mut String) {
439 let _ = writeln!(out, "}}");
440 }
441
442 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
443 let method = ctx.method.to_uppercase();
444 let fixture_id = ctx.path.trim_start_matches("/fixtures/");
445
446 let _ = writeln!(out, " var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
447 let _ = writeln!(out, " defer _ = gpa.deinit();");
448 let _ = writeln!(out, " const allocator = gpa.allocator();");
449
450 let _ = writeln!(out, " var url_buf: [512]u8 = undefined;");
451 let _ = writeln!(
452 out,
453 " 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\"}});"
454 );
455
456 if !ctx.headers.is_empty() {
458 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
459 header_pairs.sort_by_key(|(k, _)| k.as_str());
460 let _ = writeln!(out, " const headers = [_]std.http.Header{{");
461 for (k, v) in &header_pairs {
462 let ek = escape_zig(k);
463 let ev = escape_zig(v);
464 let _ = writeln!(out, " .{{ .name = \"{ek}\", .value = \"{ev}\" }},");
465 }
466 let _ = writeln!(out, " }};");
467 }
468
469 if let Some(body) = ctx.body {
471 let json_str = serde_json::to_string(body).unwrap_or_default();
472 let escaped = escape_zig(&json_str);
473 let _ = writeln!(out, " const body_bytes: []const u8 = \"{escaped}\";");
474 }
475
476 let headers_arg = if ctx.headers.is_empty() { "&.{}" } else { "&headers" };
477 let has_body = ctx.body.is_some();
478
479 let _ = writeln!(
480 out,
481 " var http_client = std.http.Client{{ .allocator = allocator }};"
482 );
483 let _ = writeln!(out, " defer http_client.deinit();");
484 let _ = writeln!(out, " var response_body = std.ArrayList(u8).init(allocator);");
485 let _ = writeln!(out, " defer response_body.deinit();");
486
487 let method_zig = match method.as_str() {
488 "GET" => ".GET",
489 "POST" => ".POST",
490 "PUT" => ".PUT",
491 "DELETE" => ".DELETE",
492 "PATCH" => ".PATCH",
493 "HEAD" => ".HEAD",
494 "OPTIONS" => ".OPTIONS",
495 _ => ".GET",
496 };
497
498 let payload_field = if has_body { ", .payload = body_bytes" } else { "" };
499 let _ = writeln!(
500 out,
501 " const {rv} = try http_client.fetch(.{{ .location = .{{ .url = url }}, .method = {method_zig}, .extra_headers = {headers_arg}{payload_field}, .response_storage = .{{ .dynamic = &response_body }} }});",
502 rv = ctx.response_var,
503 );
504 }
505
506 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
507 let _ = writeln!(
508 out,
509 " try testing.expectEqual(@as(u10, {status}), @intFromEnum({response_var}.status));"
510 );
511 }
512
513 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
514 let ename = escape_zig(&name.to_lowercase());
515 match expected {
516 "<<present>>" => {
517 let _ = writeln!(
518 out,
519 " // assert header '{ename}' is present (header inspection not yet implemented)"
520 );
521 }
522 "<<absent>>" => {
523 let _ = writeln!(
524 out,
525 " // assert header '{ename}' is absent (header inspection not yet implemented)"
526 );
527 }
528 "<<uuid>>" => {
529 let _ = writeln!(
530 out,
531 " // assert header '{ename}' matches UUID pattern (header inspection not yet implemented)"
532 );
533 }
534 exact => {
535 let evalue = escape_zig(exact);
536 let _ = writeln!(
537 out,
538 " // assert header '{ename}' == \"{evalue}\" (header inspection not yet implemented)"
539 );
540 }
541 }
542 }
543
544 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
545 let json_str = serde_json::to_string(expected).unwrap_or_default();
546 let escaped = escape_zig(&json_str);
547 let _ = writeln!(
548 out,
549 " try testing.expectEqualStrings(\"{escaped}\", response_body.items);"
550 );
551 }
552
553 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
554 if let Some(obj) = expected.as_object() {
555 for (key, val) in obj {
556 let ekey = escape_zig(key);
557 let eval = escape_zig(&serde_json::to_string(val).unwrap_or_default());
558 let _ = writeln!(
559 out,
560 " // assert body contains field \"{ekey}\" = \"{eval}\" (partial JSON not yet implemented)"
561 );
562 }
563 }
564 }
565
566 fn render_assert_validation_errors(
567 &self,
568 out: &mut String,
569 _response_var: &str,
570 errors: &[crate::fixture::ValidationErrorExpectation],
571 ) {
572 for ve in errors {
573 let loc = ve.loc.join(".");
574 let escaped_loc = escape_zig(&loc);
575 let escaped_msg = escape_zig(&ve.msg);
576 let _ = writeln!(
577 out,
578 " // assert validation error at \"{escaped_loc}\": \"{escaped_msg}\" (not yet implemented)"
579 );
580 }
581 }
582}
583
584fn render_http_test_case(out: &mut String, fixture: &Fixture) {
589 client::http_call::render_http_test(out, &ZigTestClientRenderer, fixture);
590}
591
592#[allow(clippy::too_many_arguments)]
597fn render_test_file(
598 category: &str,
599 fixtures: &[&Fixture],
600 e2e_config: &E2eConfig,
601 function_name: &str,
602 result_var: &str,
603 args: &[crate::config::ArgMapping],
604 module_name: &str,
605 ffi_prefix: &str,
606) -> String {
607 let mut out = String::new();
608 out.push_str(&hash::header(CommentStyle::DoubleSlash));
609 let _ = writeln!(out, "const std = @import(\"std\");");
610 let _ = writeln!(out, "const testing = std.testing;");
611 let _ = writeln!(out, "const {module_name} = @import(\"{module_name}\");");
612 let _ = writeln!(out);
613
614 let _ = writeln!(out, "// E2e tests for category: {category}");
615 let _ = writeln!(out);
616
617 for fixture in fixtures {
618 if fixture.http.is_some() {
619 render_http_test_case(&mut out, fixture);
620 } else {
621 render_test_fn(
622 &mut out,
623 fixture,
624 e2e_config,
625 function_name,
626 result_var,
627 args,
628 module_name,
629 ffi_prefix,
630 );
631 }
632 let _ = writeln!(out);
633 }
634
635 out
636}
637
638#[allow(clippy::too_many_arguments)]
639fn render_test_fn(
640 out: &mut String,
641 fixture: &Fixture,
642 e2e_config: &E2eConfig,
643 _function_name: &str,
644 _result_var: &str,
645 _args: &[crate::config::ArgMapping],
646 module_name: &str,
647 ffi_prefix: &str,
648) {
649 let call_config = e2e_config.resolve_call_for_fixture(
651 fixture.call.as_deref(),
652 &fixture.id,
653 &fixture.resolved_category(),
654 &fixture.tags,
655 &fixture.input,
656 );
657 let call_field_resolver = FieldResolver::new(
658 e2e_config.effective_fields(call_config),
659 e2e_config.effective_fields_optional(call_config),
660 e2e_config.effective_result_fields(call_config),
661 e2e_config.effective_fields_array(call_config),
662 e2e_config.effective_fields_method_calls(call_config),
663 );
664 let field_resolver = &call_field_resolver;
665 let enum_fields = e2e_config.effective_fields_enum(call_config);
666 let lang = "zig";
667 let call_overrides = call_config.overrides.get(lang);
668 let function_name = call_overrides
669 .and_then(|o| o.function.as_ref())
670 .cloned()
671 .unwrap_or_else(|| call_config.function.clone());
672 let result_var = &call_config.result_var;
673 let args = &call_config.args;
674 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
679 e2e_config
680 .call
681 .overrides
682 .get(lang)
683 .and_then(|o| o.client_factory.as_deref())
684 });
685
686 let call_result_is_bytes = call_config.result_is_bytes || call_config.overrides.values().any(|o| o.result_is_bytes);
703 let result_is_json_struct =
704 !call_result_is_bytes && (call_overrides.is_some_and(|o| o.result_is_json_struct) || client_factory.is_some());
705
706 let result_is_option = call_overrides.is_some_and(|o| o.result_is_option) || call_config.result_is_option;
711
712 let call_returns_error_union = call_overrides.and_then(|o| o.returns_result) != Some(false);
733
734 let test_name = fixture.id.to_snake_case();
735 let description = &fixture.description;
736 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
737
738 let (setup_lines, args_str, setup_needs_gpa) = build_args_and_setup(&fixture.input, args, &fixture.id, module_name);
739 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
745 let args_str = if extra_args.is_empty() {
746 args_str
747 } else if args_str.is_empty() {
748 extra_args.join(", ")
749 } else {
750 format!("{args_str}, {}", extra_args.join(", "))
751 };
752
753 let any_happy_emits_code = fixture
756 .assertions
757 .iter()
758 .any(|a| assertion_emits_code(a, field_resolver));
759 let any_non_error_emits_code = fixture
760 .assertions
761 .iter()
762 .filter(|a| a.assertion_type != "error")
763 .any(|a| assertion_emits_code(a, field_resolver));
764
765 let has_streaming_virtual_assertions = fixture.assertions.iter().any(|a| {
767 a.field
768 .as_ref()
769 .is_some_and(|f| !f.is_empty() && is_streaming_virtual_field(f))
770 });
771 let is_stream_fn = function_name.contains("stream");
772 let uses_streaming_virtual_path =
773 result_is_json_struct && has_streaming_virtual_assertions && is_stream_fn && client_factory.is_some();
774 let streaming_path_has_non_streaming = uses_streaming_virtual_path
776 && fixture.assertions.iter().any(|a| {
777 !a.field
778 .as_ref()
779 .is_some_and(|f| !f.is_empty() && is_streaming_virtual_field(f))
780 && !matches!(a.assertion_type.as_str(), "not_error" | "error")
781 && a.field
782 .as_ref()
783 .is_some_and(|f| !f.is_empty() && field_resolver.is_valid_for_result(f))
784 });
785
786 let _ = writeln!(out, "test \"{test_name}\" {{");
787 let _ = writeln!(out, " // {description}");
788
789 if let Some(visitor_spec) = &fixture.visitor {
794 let html = fixture.input.get("html").and_then(|v| v.as_str()).unwrap_or_default();
795 let options_value = fixture.input.get("options").cloned();
796 emit_visitor_test_body(
797 out,
798 &fixture.id,
799 html,
800 options_value.as_ref(),
801 visitor_spec,
802 module_name,
803 &fixture.assertions,
804 expects_error,
805 field_resolver,
806 );
807 let _ = writeln!(out, "}}");
808 let _ = writeln!(out);
809 return;
810 }
811
812 let needs_gpa = setup_needs_gpa
820 || streaming_path_has_non_streaming
821 || (!uses_streaming_virtual_path && result_is_json_struct && !expects_error && any_happy_emits_code)
822 || (!uses_streaming_virtual_path && result_is_json_struct && expects_error && any_non_error_emits_code);
823 if needs_gpa {
824 let _ = writeln!(out, " var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
825 let _ = writeln!(out, " defer _ = gpa.deinit();");
826 let _ = writeln!(out, " const allocator = gpa.allocator();");
827 let _ = writeln!(out);
828 }
829
830 for line in &setup_lines {
831 let _ = writeln!(out, " {line}");
832 }
833
834 let call_prefix = if let Some(factory) = client_factory {
839 let fixture_id = &fixture.id;
840 let _ = writeln!(
841 out,
842 " 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);"
843 );
844 let _ = writeln!(out, " defer std.heap.c_allocator.free(_mock_url);");
845 let _ = writeln!(
846 out,
847 " var _client = try {module_name}.{factory}(\"test-key\", _mock_url, null, null, null);"
848 );
849 let _ = writeln!(out, " defer _client.free();");
850 "_client".to_string()
851 } else {
852 module_name.to_string()
853 };
854
855 if expects_error {
856 if result_is_json_struct {
860 let _ = writeln!(
861 out,
862 " const _result_json = {call_prefix}.{function_name}({args_str}) catch {{"
863 );
864 } else {
865 let _ = writeln!(
866 out,
867 " const result = {call_prefix}.{function_name}({args_str}) catch {{"
868 );
869 }
870 let _ = writeln!(out, " try testing.expect(true); // Error occurred as expected");
871 let _ = writeln!(out, " return;");
872 let _ = writeln!(out, " }};");
873 let any_emits_code = fixture
877 .assertions
878 .iter()
879 .filter(|a| a.assertion_type != "error")
880 .any(|a| assertion_emits_code(a, field_resolver));
881 if result_is_json_struct && any_emits_code {
882 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
883 let _ = writeln!(
884 out,
885 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
886 );
887 let _ = writeln!(out, " defer _parsed.deinit();");
888 let _ = writeln!(out, " const {result_var} = &_parsed.value;");
889 let _ = writeln!(out, " // Perform success assertions if any");
890 for assertion in &fixture.assertions {
891 if assertion.assertion_type != "error" {
892 render_json_assertion(out, assertion, result_var, field_resolver, false);
893 }
894 }
895 } else if result_is_json_struct {
896 let _ = writeln!(out, " _ = _result_json;");
897 } else if any_emits_code {
898 let _ = writeln!(out, " // Perform success assertions if any");
899 for assertion in &fixture.assertions {
900 if assertion.assertion_type != "error" {
901 render_assertion(
902 out,
903 assertion,
904 result_var,
905 field_resolver,
906 enum_fields,
907 result_is_option,
908 );
909 }
910 }
911 } else {
912 let _ = writeln!(out, " _ = result;");
913 }
914 } else if fixture.assertions.is_empty() {
915 if result_is_json_struct {
917 let _ = writeln!(
918 out,
919 " const _result_json = try {call_prefix}.{function_name}({args_str});"
920 );
921 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
922 } else if call_returns_error_union {
923 let _ = writeln!(out, " _ = try {call_prefix}.{function_name}({args_str});");
924 } else {
925 let _ = writeln!(out, " _ = {call_prefix}.{function_name}({args_str});");
926 }
927 } else {
928 let any_emits_code = fixture
932 .assertions
933 .iter()
934 .any(|a| assertion_emits_code(a, field_resolver));
935 if call_result_is_bytes && client_factory.is_some() {
936 let _ = writeln!(
939 out,
940 " const _result_json = try {call_prefix}.{function_name}({args_str});"
941 );
942 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
943 let has_bytes_assertions = fixture
944 .assertions
945 .iter()
946 .any(|a| matches!(a.assertion_type.as_str(), "not_empty" | "is_empty"));
947 if has_bytes_assertions {
948 for assertion in &fixture.assertions {
949 match assertion.assertion_type.as_str() {
950 "not_empty" => {
951 let _ = writeln!(out, " try testing.expect(_result_json.len > 0);");
952 }
953 "is_empty" => {
954 let _ = writeln!(out, " try testing.expectEqual(@as(usize, 0), _result_json.len);");
955 }
956 "not_error" | "error" => {}
957 _ => {
958 let atype = &assertion.assertion_type;
959 let _ = writeln!(
960 out,
961 " // bytes result: assertion '{atype}' not implemented for zig bytes"
962 );
963 }
964 }
965 }
966 }
967 } else if result_is_json_struct {
968 if uses_streaming_virtual_path {
972 let request_from_json = format!("{ffi_prefix}_chat_completion_request_from_json");
973 let request_free = format!("{ffi_prefix}_chat_completion_request_free");
974 let stream_start = format!("{ffi_prefix}_default_client_chat_stream_start");
975 let stream_free = format!("{ffi_prefix}_default_client_chat_stream_free");
976 let client_c_type = format!("{}DefaultClient", ffi_prefix.to_shouty_snake_case());
977
978 let _ = writeln!(
981 out,
982 " const _req_z = try std.heap.c_allocator.dupeZ(u8, {args_str});"
983 );
984 let _ = writeln!(out, " defer std.heap.c_allocator.free(_req_z);");
985 let _ = writeln!(
986 out,
987 " const _req_handle = {module_name}.c.{request_from_json}(_req_z.ptr);"
988 );
989 let _ = writeln!(out, " defer {module_name}.c.{request_free}(_req_handle);");
990 let _ = writeln!(
991 out,
992 " const _stream_handle = {module_name}.c.{stream_start}(@as(*{module_name}.c.{client_c_type}, @ptrCast(_client._handle)), _req_handle);"
993 );
994 let _ = writeln!(out, " if (_stream_handle == null) return error.StreamStartFailed;");
995 let _ = writeln!(out, " defer {module_name}.c.{stream_free}(_stream_handle);");
996 let snip =
998 StreamingFieldResolver::collect_snippet_zig("_stream_handle", "chunks", module_name, ffi_prefix);
999 out.push_str(" ");
1000 out.push_str(&snip);
1001 out.push('\n');
1002 if streaming_path_has_non_streaming {
1005 let _ = writeln!(
1006 out,
1007 " const _result_json = if (chunks.items.len > 0) chunks.items[chunks.items.len - 1] else &[_]u8{{}};"
1008 );
1009 let _ = writeln!(
1010 out,
1011 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
1012 );
1013 let _ = writeln!(out, " defer _parsed.deinit();");
1014 let _ = writeln!(out, " const {result_var} = &_parsed.value;");
1015 }
1016 for assertion in &fixture.assertions {
1017 render_json_assertion(out, assertion, result_var, field_resolver, true);
1018 }
1019 } else {
1020 let _ = writeln!(
1022 out,
1023 " const _result_json = try {call_prefix}.{function_name}({args_str});"
1024 );
1025 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
1026 if any_emits_code {
1027 let _ = writeln!(
1028 out,
1029 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
1030 );
1031 let _ = writeln!(out, " defer _parsed.deinit();");
1032 let _ = writeln!(out, " const {result_var} = &_parsed.value;");
1033 for assertion in &fixture.assertions {
1034 render_json_assertion(out, assertion, result_var, field_resolver, false);
1035 }
1036 }
1037 }
1038 } else if any_emits_code {
1039 let try_kw = if call_returns_error_union { "try " } else { "" };
1040 let _ = writeln!(
1041 out,
1042 " const {result_var} = {try_kw}{call_prefix}.{function_name}({args_str});"
1043 );
1044 for assertion in &fixture.assertions {
1045 render_assertion(
1046 out,
1047 assertion,
1048 result_var,
1049 field_resolver,
1050 enum_fields,
1051 result_is_option,
1052 );
1053 }
1054 } else if call_returns_error_union {
1055 let _ = writeln!(out, " _ = try {call_prefix}.{function_name}({args_str});");
1056 } else {
1057 let _ = writeln!(out, " _ = {call_prefix}.{function_name}({args_str});");
1058 }
1059 }
1060
1061 let _ = writeln!(out, "}}");
1062}
1063
1064#[allow(clippy::too_many_arguments)]
1070fn emit_visitor_test_body(
1071 out: &mut String,
1072 fixture_id: &str,
1073 html: &str,
1074 options_value: Option<&serde_json::Value>,
1075 visitor_spec: &crate::fixture::VisitorSpec,
1076 module_name: &str,
1077 assertions: &[Assertion],
1078 expects_error: bool,
1079 field_resolver: &FieldResolver,
1080) {
1081 let _ = writeln!(out, " var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
1083 let _ = writeln!(out, " defer _ = gpa.deinit();");
1084 let _ = writeln!(out, " const allocator = gpa.allocator();");
1085 let _ = writeln!(out);
1086
1087 let visitor_block = super::zig_visitors::build_zig_visitor(fixture_id, module_name, visitor_spec);
1089 out.push_str(&visitor_block);
1090
1091 let _ = writeln!(
1094 out,
1095 " const _visitor = {module_name}.c.htm_visitor_create(&_callbacks);"
1096 );
1097 let _ = writeln!(out, " defer {module_name}.c.htm_visitor_free(_visitor);");
1098
1099 let options_json = match options_value {
1103 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1104 None => "{}".to_string(),
1105 };
1106 let escaped_options = escape_zig(&options_json);
1107 let _ = writeln!(
1108 out,
1109 " const _options_z = try std.heap.c_allocator.dupeZ(u8, \"{escaped_options}\");"
1110 );
1111 let _ = writeln!(out, " defer std.heap.c_allocator.free(_options_z);");
1112 let _ = writeln!(
1113 out,
1114 " const _options = {module_name}.c.htm_conversion_options_from_json(_options_z.ptr);"
1115 );
1116 let _ = writeln!(out, " defer {module_name}.c.htm_conversion_options_free(_options);");
1117 let _ = writeln!(
1118 out,
1119 " {module_name}.c.htm_options_set_visitor_handle(_options, _visitor);"
1120 );
1121
1122 let escaped_html = escape_zig(html);
1124 let _ = writeln!(
1125 out,
1126 " const _html_z = try std.heap.c_allocator.dupeZ(u8, \"{escaped_html}\");"
1127 );
1128 let _ = writeln!(out, " defer std.heap.c_allocator.free(_html_z);");
1129 let _ = writeln!(
1130 out,
1131 " const _result = {module_name}.c.htm_convert(_html_z.ptr, _options);"
1132 );
1133
1134 if expects_error {
1135 let _ = writeln!(
1137 out,
1138 " try testing.expect(_result == null or {module_name}.c.htm_last_error_code() != 0);"
1139 );
1140 let _ = writeln!(
1141 out,
1142 " if (_result) |r| {module_name}.c.htm_conversion_result_free(r);"
1143 );
1144 return;
1145 }
1146
1147 let _ = writeln!(out, " try testing.expect(_result != null);");
1148 let _ = writeln!(out, " defer {module_name}.c.htm_conversion_result_free(_result.?);");
1149 let _ = writeln!(
1150 out,
1151 " const _json_ptr = {module_name}.c.htm_conversion_result_to_json(_result.?);"
1152 );
1153 let _ = writeln!(out, " defer {module_name}.c.htm_free_string(_json_ptr);");
1154 let _ = writeln!(out, " const _result_json = std.mem.sliceTo(_json_ptr, 0);");
1155 let _ = writeln!(
1156 out,
1157 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
1158 );
1159 let _ = writeln!(out, " defer _parsed.deinit();");
1160 let _ = writeln!(out, " const result = &_parsed.value;");
1161
1162 for assertion in assertions {
1163 if assertion.assertion_type != "error" {
1164 render_json_assertion(out, assertion, "result", field_resolver, false);
1165 }
1166 }
1167}
1168
1169const FORMAT_METADATA_VARIANTS: &[&str] = &[
1187 "pdf",
1188 "docx",
1189 "excel",
1190 "email",
1191 "pptx",
1192 "archive",
1193 "image",
1194 "xml",
1195 "text",
1196 "html",
1197 "ocr",
1198 "csv",
1199 "bibtex",
1200 "citation",
1201 "fiction_book",
1202 "dbf",
1203 "jats",
1204 "epub",
1205 "pst",
1206 "code",
1207];
1208
1209fn json_path_expr(result_var: &str, field_path: &str) -> String {
1210 let segments: Vec<&str> = field_path.split('.').collect();
1211 let mut expr = result_var.to_string();
1212 let mut prev_seg: Option<&str> = None;
1213 for seg in &segments {
1214 if prev_seg == Some("format") && FORMAT_METADATA_VARIANTS.contains(seg) {
1219 prev_seg = Some(seg);
1220 continue;
1221 }
1222 if let Some(key) = seg.strip_suffix("[]") {
1226 expr = format!("{expr}.object.get(\"{key}\").?.array.items[0]");
1227 } else if let Some(bracket_pos) = seg.find('[') {
1228 if let Some(end_pos) = seg.find(']') {
1229 if end_pos > bracket_pos + 1 && end_pos == seg.len() - 1 {
1230 let key = &seg[..bracket_pos];
1231 let idx = &seg[bracket_pos + 1..end_pos];
1232 if idx.chars().all(|c| c.is_ascii_digit()) {
1233 expr = format!("{expr}.object.get(\"{key}\").?.array.items[{idx}]");
1234 prev_seg = Some(seg);
1235 continue;
1236 }
1237 expr = format!("{expr}.object.get(\"{key}\").?.object.get(\"{idx}\").?");
1243 prev_seg = Some(seg);
1244 continue;
1245 }
1246 }
1247 expr = format!("{expr}.object.get(\"{seg}\").?");
1248 } else {
1249 expr = format!("{expr}.object.get(\"{seg}\").?");
1250 }
1251 prev_seg = Some(seg);
1252 }
1253 expr
1254}
1255
1256fn emit_zig_chunks_predicate(
1261 out: &mut String,
1262 result_var: &str,
1263 assertion_type: &str,
1264 chunk_field_accessor: &str,
1265 field_name: &str,
1266 require_non_empty_string: bool,
1267) {
1268 let _ = writeln!(out, " {{");
1269 let _ = writeln!(out, " const _chunks_opt = {result_var}.object.get(\"chunks\");");
1270 let _ = writeln!(out, " var _all: bool = true;");
1271 let _ = writeln!(out, " if (_chunks_opt) |_chunks_val| {{");
1272 let _ = writeln!(out, " if (_chunks_val == .array) {{");
1273 let _ = writeln!(
1274 out,
1275 " if (_chunks_val.array.items.len == 0) _all = false;"
1276 );
1277 let _ = writeln!(out, " for (_chunks_val.array.items) |c| {{");
1278 let _ = writeln!(out, " if (c != .object) {{ _all = false; break; }}");
1279 let _ = writeln!(out, " const _v = {chunk_field_accessor};");
1280 if require_non_empty_string {
1281 let _ = writeln!(
1282 out,
1283 " if (_v == null or _v.? != .string or _v.?.string.len == 0) {{ _all = false; break; }}"
1284 );
1285 } else {
1286 let _ = writeln!(
1287 out,
1288 " if (_v == null or _v.? == .null) {{ _all = false; break; }}"
1289 );
1290 }
1291 let _ = writeln!(out, " }}");
1292 let _ = writeln!(out, " }} else {{ _all = false; }}");
1293 let _ = writeln!(out, " }} else {{ _all = false; }}");
1294 match assertion_type {
1295 "is_true" => {
1296 let _ = writeln!(out, " try testing.expect(_all);");
1297 }
1298 "is_false" => {
1299 let _ = writeln!(out, " try testing.expect(!_all);");
1300 }
1301 _ => {
1302 let _ = writeln!(
1303 out,
1304 " // skipped: unsupported assertion type on synthetic field '{field_name}'"
1305 );
1306 }
1307 }
1308 let _ = writeln!(out, " }}");
1309}
1310
1311fn render_json_assertion(
1316 out: &mut String,
1317 assertion: &Assertion,
1318 result_var: &str,
1319 field_resolver: &FieldResolver,
1320 uses_streaming: bool,
1321) {
1322 if let Some(f) = &assertion.field {
1329 if uses_streaming && !f.is_empty() && is_streaming_virtual_field(f) {
1330 if let Some(expr) = StreamingFieldResolver::accessor(f, "zig", "chunks") {
1331 match assertion.assertion_type.as_str() {
1332 "count_min" => {
1333 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1334 let _ = writeln!(out, " try testing.expect({expr}.len >= {n});");
1335 }
1336 }
1337 "count_equals" => {
1338 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1339 let _ = writeln!(out, " try testing.expectEqual(@as(usize, {n}), {expr}.len);");
1340 }
1341 }
1342 "equals" => {
1343 if let Some(serde_json::Value::String(s)) = &assertion.value {
1344 let escaped = escape_zig(s);
1345 let _ = writeln!(out, " try testing.expectEqualStrings(\"{escaped}\", {expr});");
1346 } else if let Some(v) = &assertion.value {
1347 let zig_val = json_to_zig(v);
1348 let _ = writeln!(out, " try testing.expectEqual({zig_val}, {expr});");
1349 }
1350 }
1351 "not_empty" => {
1352 let _ = writeln!(out, " try testing.expect({expr}.len > 0);");
1353 }
1354 "is_true" => {
1355 let _ = writeln!(out, " try testing.expect({expr});");
1356 }
1357 "is_false" => {
1358 let _ = writeln!(out, " try testing.expect(!{expr});");
1359 }
1360 _ => {
1361 let atype = &assertion.assertion_type;
1362 let _ = writeln!(
1363 out,
1364 " // streaming virtual field '{f}' assertion '{atype}' not implemented for zig"
1365 );
1366 }
1367 }
1368 }
1369 return;
1370 }
1371 }
1372
1373 if let Some(f) = &assertion.field {
1380 if f == "embeddings" && !field_resolver.has_explicit_field("embeddings") {
1381 match assertion.assertion_type.as_str() {
1382 "count_min" => {
1383 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1384 let _ = writeln!(out, " try testing.expect({result_var}.array.items.len >= {n});");
1385 }
1386 return;
1387 }
1388 "count_equals" => {
1389 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1390 let _ = writeln!(
1391 out,
1392 " try testing.expectEqual(@as(usize, {n}), {result_var}.array.items.len);"
1393 );
1394 }
1395 return;
1396 }
1397 "not_empty" => {
1398 let _ = writeln!(out, " try testing.expect({result_var}.array.items.len > 0);");
1399 return;
1400 }
1401 "is_empty" => {
1402 let _ = writeln!(
1403 out,
1404 " try testing.expectEqual(@as(usize, 0), {result_var}.array.items.len);"
1405 );
1406 return;
1407 }
1408 _ => {}
1409 }
1410 }
1411 }
1412
1413 if let Some(f) = &assertion.field {
1419 match f.as_str() {
1420 "chunks_have_content" => {
1421 emit_zig_chunks_predicate(
1422 out,
1423 result_var,
1424 assertion.assertion_type.as_str(),
1425 "c.object.get(\"content\")",
1426 "chunks_have_content",
1427 true,
1428 );
1429 return;
1430 }
1431 "chunks_have_heading_context" => {
1432 let _ = writeln!(
1437 out,
1438 " // skipped: synthetic field 'chunks_have_heading_context' not derivable from JSON value alone"
1439 );
1440 return;
1441 }
1442 "first_chunk_starts_with_heading" => {
1443 let _ = writeln!(
1444 out,
1445 " // skipped: synthetic field 'first_chunk_starts_with_heading' not derivable from JSON value alone"
1446 );
1447 return;
1448 }
1449 "chunks_have_embeddings" => {
1450 emit_zig_chunks_predicate(
1451 out,
1452 result_var,
1453 assertion.assertion_type.as_str(),
1454 "c.object.get(\"embedding\")",
1455 "chunks_have_embeddings",
1456 false,
1457 );
1458 return;
1459 }
1460 "keywords" | "keywords_count" => {
1465 let _ = writeln!(
1466 out,
1467 " // skipped: field '{f}' not available on JSON-struct ExtractionResult"
1468 );
1469 return;
1470 }
1471 _ => {}
1472 }
1473 }
1474
1475 if let Some(f) = &assertion.field {
1477 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1478 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1479 return;
1480 }
1481 }
1482 if matches!(assertion.assertion_type.as_str(), "not_error" | "error") {
1484 return;
1485 }
1486
1487 let raw_field_path = assertion.field.as_deref().unwrap_or("").trim();
1488 let field_path = if raw_field_path.is_empty() {
1489 raw_field_path.to_string()
1490 } else {
1491 field_resolver.resolve(raw_field_path).to_string()
1492 };
1493 let field_path = field_path.trim();
1494
1495 let (field_path_for_expr, is_length_access) = if let Some(parent) = field_path.strip_suffix(".length") {
1497 (parent, true)
1498 } else {
1499 (field_path, false)
1500 };
1501
1502 let field_expr = if field_path_for_expr.is_empty() {
1503 result_var.to_string()
1504 } else {
1505 json_path_expr(result_var, field_path_for_expr)
1506 };
1507
1508 if field_path_for_expr == "metadata.format"
1514 && matches!(
1515 assertion.assertion_type.as_str(),
1516 "equals" | "contains" | "not_empty" | "is_empty" | "starts_with" | "ends_with"
1517 )
1518 {
1519 let base = json_path_expr(result_var, field_path_for_expr);
1520 let _ = writeln!(out, " {{");
1521 let _ = writeln!(out, " const _fmt_obj = {base}.object;");
1522 let _ = writeln!(out, " const _fmt_type = _fmt_obj.get(\"format_type\").?.string;");
1523 let _ = writeln!(
1524 out,
1525 " const _fmt_display: []const u8 = if (std.mem.eql(u8, _fmt_type, \"image\")) _fmt_obj.get(\"format\").?.string else _fmt_type;"
1526 );
1527 match assertion.assertion_type.as_str() {
1528 "equals" => {
1529 if let Some(serde_json::Value::String(s)) = &assertion.value {
1530 let escaped = escape_zig(s);
1531 let _ = writeln!(
1532 out,
1533 " try testing.expectEqualStrings(\"{escaped}\", std.mem.trim(u8, _fmt_display, \" \\n\\r\\t\"));"
1534 );
1535 }
1536 }
1537 "contains" => {
1538 if let Some(serde_json::Value::String(s)) = &assertion.value {
1539 let escaped = escape_zig(s);
1540 let _ = writeln!(
1541 out,
1542 " try testing.expect(std.mem.indexOf(u8, _fmt_display, \"{escaped}\") != null);"
1543 );
1544 }
1545 }
1546 "starts_with" => {
1547 if let Some(serde_json::Value::String(s)) = &assertion.value {
1548 let escaped = escape_zig(s);
1549 let _ = writeln!(
1550 out,
1551 " try testing.expect(std.mem.startsWith(u8, _fmt_display, \"{escaped}\"));"
1552 );
1553 }
1554 }
1555 "ends_with" => {
1556 if let Some(serde_json::Value::String(s)) = &assertion.value {
1557 let escaped = escape_zig(s);
1558 let _ = writeln!(
1559 out,
1560 " try testing.expect(std.mem.endsWith(u8, _fmt_display, \"{escaped}\"));"
1561 );
1562 }
1563 }
1564 "not_empty" => {
1565 let _ = writeln!(out, " try testing.expect(_fmt_display.len > 0);");
1566 }
1567 "is_empty" => {
1568 let _ = writeln!(out, " try testing.expectEqual(@as(usize, 0), _fmt_display.len);");
1569 }
1570 _ => {}
1571 }
1572 let _ = writeln!(out, " }}");
1573 return;
1574 }
1575
1576 let zig_val = match &assertion.value {
1578 Some(serde_json::Value::String(s)) => format!("\"{}\"", escape_zig(s)),
1579 _ => String::new(),
1580 };
1581 let is_string_val = matches!(&assertion.value, Some(serde_json::Value::String(_)));
1582 let is_bool_val = matches!(&assertion.value, Some(serde_json::Value::Bool(_)));
1583 let bool_val = match &assertion.value {
1584 Some(serde_json::Value::Bool(b)) if *b => "true",
1585 _ => "false",
1586 };
1587 let is_null_val = matches!(&assertion.value, Some(serde_json::Value::Null));
1588 let n = assertion.value.as_ref().map(json_to_zig).unwrap_or_default();
1589 let has_n = assertion.value.as_ref().is_some_and(|v| v.is_number() || v.is_u64());
1590 let is_float_val = matches!(&assertion.value, Some(serde_json::Value::Number(n)) if !n.is_i64() && !n.is_u64());
1595 let values_list: Vec<String> = assertion
1596 .values
1597 .as_deref()
1598 .unwrap_or_default()
1599 .iter()
1600 .filter_map(|v| {
1601 if let serde_json::Value::String(s) = v {
1602 Some(format!("\"{}\"", escape_zig(s)))
1603 } else {
1604 None
1605 }
1606 })
1607 .collect();
1608
1609 let rendered = crate::template_env::render(
1610 "zig/json_assertion.jinja",
1611 minijinja::context! {
1612 assertion_type => assertion.assertion_type.as_str(),
1613 field_expr => field_expr,
1614 is_length_access => is_length_access,
1615 zig_val => zig_val,
1616 is_string_val => is_string_val,
1617 is_bool_val => is_bool_val,
1618 bool_val => bool_val,
1619 is_null_val => is_null_val,
1620 n => n,
1621 has_n => has_n,
1622 is_float_val => is_float_val,
1623 values_list => values_list,
1624 },
1625 );
1626 out.push_str(&rendered);
1627}
1628
1629fn assertion_emits_code(assertion: &Assertion, field_resolver: &FieldResolver) -> bool {
1632 if let Some(f) = &assertion.field {
1633 if !f.is_empty() && is_streaming_virtual_field(f) {
1634 } else if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1637 return false;
1638 }
1639 }
1640 matches!(
1641 assertion.assertion_type.as_str(),
1642 "equals"
1643 | "contains"
1644 | "contains_all"
1645 | "not_contains"
1646 | "not_empty"
1647 | "is_empty"
1648 | "starts_with"
1649 | "ends_with"
1650 | "min_length"
1651 | "max_length"
1652 | "count_min"
1653 | "count_equals"
1654 | "is_true"
1655 | "is_false"
1656 | "greater_than"
1657 | "less_than"
1658 | "greater_than_or_equal"
1659 | "less_than_or_equal"
1660 | "contains_any"
1661 )
1662}
1663
1664fn build_args_and_setup(
1669 input: &serde_json::Value,
1670 args: &[crate::config::ArgMapping],
1671 fixture_id: &str,
1672 _module_name: &str,
1673) -> (Vec<String>, String, bool) {
1674 if args.is_empty() {
1675 return (Vec::new(), String::new(), false);
1676 }
1677
1678 let mut setup_lines: Vec<String> = Vec::new();
1679 let mut parts: Vec<String> = Vec::new();
1680 let mut setup_needs_gpa = false;
1681
1682 for arg in args {
1683 if arg.arg_type == "mock_url" {
1684 let name = arg.name.clone();
1685 let id_upper = fixture_id.to_uppercase();
1686 setup_lines.push(format!(
1687 "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\"}});"
1688 ));
1689 setup_lines.push(format!("defer allocator.free({name});"));
1690 parts.push(name);
1691 setup_needs_gpa = true;
1692 continue;
1693 }
1694
1695 if arg.arg_type == "handle" {
1698 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1699 let json_str = match input.get(field) {
1700 Some(serde_json::Value::Null) | None => "null".to_string(),
1701 Some(v) => format!("\"{}\"", escape_zig(&serde_json::to_string(v).unwrap_or_default())),
1702 };
1703 parts.push(json_str);
1704 continue;
1705 }
1706
1707 if arg.name == "config" && arg.arg_type == "json_object" {
1714 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1715 let json_str = match input.get(field) {
1716 Some(serde_json::Value::Null) | None => "{}".to_string(),
1717 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1718 };
1719 parts.push(format!("\"{}\"", escape_zig(&json_str)));
1720 continue;
1721 }
1722
1723 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1724 let val = if field.is_empty() || field == "input" {
1730 Some(input)
1731 } else {
1732 input.get(field)
1733 };
1734 match val {
1735 None | Some(serde_json::Value::Null) if arg.optional => {
1736 parts.push("null".to_string());
1739 }
1740 None | Some(serde_json::Value::Null) => {
1741 let default_val = match arg.arg_type.as_str() {
1742 "string" => "\"\"".to_string(),
1743 "int" | "integer" => "0".to_string(),
1744 "float" | "number" => "0.0".to_string(),
1745 "bool" | "boolean" => "false".to_string(),
1746 "json_object" => "\"{}\"".to_string(),
1747 _ => "null".to_string(),
1748 };
1749 parts.push(default_val);
1750 }
1751 Some(v) => {
1752 if arg.arg_type == "json_object" {
1757 let json_str = serde_json::to_string(v).unwrap_or_default();
1758 parts.push(format!("\"{}\"", escape_zig(&json_str)));
1759 } else if arg.arg_type == "bytes" {
1760 if let serde_json::Value::String(path) = v {
1765 let var_name = format!("{}_bytes", arg.name);
1766 let epath = escape_zig(path);
1767 setup_lines.push(format!(
1768 "const {var_name} = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, \"{epath}\", std.heap.c_allocator, .unlimited);"
1769 ));
1770 setup_lines.push(format!("defer std.heap.c_allocator.free({var_name});"));
1771 parts.push(var_name);
1772 } else {
1773 parts.push(json_to_zig(v));
1774 }
1775 } else {
1776 parts.push(json_to_zig(v));
1777 }
1778 }
1779 }
1780 }
1781
1782 (setup_lines, parts.join(", "), setup_needs_gpa)
1783}
1784
1785fn render_assertion(
1786 out: &mut String,
1787 assertion: &Assertion,
1788 result_var: &str,
1789 field_resolver: &FieldResolver,
1790 enum_fields: &HashSet<String>,
1791 result_is_option: bool,
1792) {
1793 let bare_result_is_option = result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1796 if bare_result_is_option {
1797 match assertion.assertion_type.as_str() {
1798 "is_empty" => {
1799 let _ = writeln!(out, " try testing.expect({result_var} == null);");
1800 return;
1801 }
1802 "not_empty" => {
1803 let _ = writeln!(out, " try testing.expect({result_var} != null);");
1804 return;
1805 }
1806 "not_error" => {
1807 let _ = writeln!(out, " // not_error: covered by try propagation");
1812 return;
1813 }
1814 "equals" => {
1815 if let Some(expected) = &assertion.value {
1816 let zig_val = json_to_zig(expected);
1817 let _ = writeln!(out, " try testing.expectEqualStrings({zig_val}, {result_var}.?);");
1818 return;
1819 }
1820 }
1821 _ => {}
1822 }
1823 }
1824 if let Some(f) = &assertion.field {
1837 if f == "embeddings" && !field_resolver.has_explicit_field(f) {
1838 match assertion.assertion_type.as_str() {
1839 "count_min" | "count_equals" | "not_empty" | "is_empty" => {
1840 let _ = writeln!(out, " {{");
1841 let _ = writeln!(
1842 out,
1843 " var _eparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {result_var}, .{{}});"
1844 );
1845 let _ = writeln!(out, " defer _eparse.deinit();");
1846 let _ = writeln!(out, " const _embeddings_len = _eparse.value.array.items.len;");
1847 match assertion.assertion_type.as_str() {
1848 "count_min" => {
1849 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1850 let _ = writeln!(out, " try testing.expect(_embeddings_len >= {n});");
1851 }
1852 }
1853 "count_equals" => {
1854 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1855 let _ = writeln!(
1856 out,
1857 " try testing.expectEqual(@as(usize, {n}), _embeddings_len);"
1858 );
1859 }
1860 }
1861 "not_empty" => {
1862 let _ = writeln!(out, " try testing.expect(_embeddings_len > 0);");
1863 }
1864 "is_empty" => {
1865 let _ = writeln!(out, " try testing.expectEqual(@as(usize, 0), _embeddings_len);");
1866 }
1867 _ => {}
1868 }
1869 let _ = writeln!(out, " }}");
1870 return;
1871 }
1872 _ => {}
1873 }
1874 }
1875 }
1876
1877 if let Some(f) = &assertion.field {
1883 if f == "result" && !field_resolver.has_explicit_field(f) {
1884 match assertion.assertion_type.as_str() {
1885 "contains" => {
1886 if let Some(expected) = &assertion.value {
1887 let zig_val = json_to_zig(expected);
1888 let _ = writeln!(
1889 out,
1890 " try testing.expect(std.mem.indexOf(u8, {result_var}, {zig_val}) != null);"
1891 );
1892 return;
1893 }
1894 }
1895 "not_contains" => {
1896 if let Some(expected) = &assertion.value {
1897 let zig_val = json_to_zig(expected);
1898 let _ = writeln!(
1899 out,
1900 " try testing.expect(std.mem.indexOf(u8, {result_var}, {zig_val}) == null);"
1901 );
1902 return;
1903 }
1904 }
1905 "equals" => {
1906 if let Some(expected) = &assertion.value {
1907 let zig_val = json_to_zig(expected);
1908 let _ = writeln!(out, " try testing.expectEqualStrings({zig_val}, {result_var});");
1909 return;
1910 }
1911 }
1912 "not_empty" => {
1913 let _ = writeln!(out, " try testing.expect({result_var}.len > 0);");
1914 return;
1915 }
1916 "is_empty" => {
1917 let _ = writeln!(out, " try testing.expectEqual(@as(usize, 0), {result_var}.len);");
1918 return;
1919 }
1920 _ => {}
1921 }
1922 }
1923 }
1924
1925 if let Some(f) = &assertion.field {
1927 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1928 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
1929 return;
1930 }
1931 }
1932
1933 let _field_is_enum = assertion
1935 .field
1936 .as_deref()
1937 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1938
1939 let field_expr = match &assertion.field {
1940 Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
1941 _ => result_var.to_string(),
1942 };
1943
1944 match assertion.assertion_type.as_str() {
1945 "equals" => {
1946 if let Some(expected) = &assertion.value {
1947 let zig_val = json_to_zig(expected);
1948 let _ = writeln!(out, " try testing.expectEqual({zig_val}, {field_expr});");
1949 }
1950 }
1951 "contains" => {
1952 if let Some(expected) = &assertion.value {
1953 let zig_val = json_to_zig(expected);
1954 let _ = writeln!(
1955 out,
1956 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1957 );
1958 }
1959 }
1960 "contains_all" => {
1961 if let Some(values) = &assertion.values {
1962 for val in values {
1963 let zig_val = json_to_zig(val);
1964 let _ = writeln!(
1965 out,
1966 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1967 );
1968 }
1969 }
1970 }
1971 "not_contains" => {
1972 if let Some(expected) = &assertion.value {
1973 let zig_val = json_to_zig(expected);
1974 let _ = writeln!(
1975 out,
1976 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1977 );
1978 } else if let Some(values) = &assertion.values {
1979 for val in values {
1983 let zig_val = json_to_zig(val);
1984 let _ = writeln!(
1985 out,
1986 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1987 );
1988 }
1989 }
1990 }
1991 "not_empty" => {
1992 let _ = writeln!(out, " try testing.expect({field_expr}.len > 0);");
1993 }
1994 "is_empty" => {
1995 let _ = writeln!(out, " try testing.expect({field_expr}.len == 0);");
1996 }
1997 "starts_with" => {
1998 if let Some(expected) = &assertion.value {
1999 let zig_val = json_to_zig(expected);
2000 let _ = writeln!(
2001 out,
2002 " try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
2003 );
2004 }
2005 }
2006 "ends_with" => {
2007 if let Some(expected) = &assertion.value {
2008 let zig_val = json_to_zig(expected);
2009 let _ = writeln!(
2010 out,
2011 " try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
2012 );
2013 }
2014 }
2015 "min_length" => {
2016 if let Some(val) = &assertion.value {
2017 if let Some(n) = val.as_u64() {
2018 let _ = writeln!(out, " try testing.expect({field_expr}.len >= {n});");
2019 }
2020 }
2021 }
2022 "max_length" => {
2023 if let Some(val) = &assertion.value {
2024 if let Some(n) = val.as_u64() {
2025 let _ = writeln!(out, " try testing.expect({field_expr}.len <= {n});");
2026 }
2027 }
2028 }
2029 "count_min" => {
2030 if let Some(val) = &assertion.value {
2031 if let Some(n) = val.as_u64() {
2032 let _ = writeln!(out, " try testing.expect({field_expr}.len >= {n});");
2033 }
2034 }
2035 }
2036 "count_equals" => {
2037 if let Some(val) = &assertion.value {
2038 if let Some(n) = val.as_u64() {
2039 let has_field = assertion.field.as_deref().is_some_and(|f| !f.is_empty());
2043 if has_field {
2044 let _ = writeln!(out, " try testing.expectEqual({n}, {field_expr}.len);");
2045 } else {
2046 let _ = writeln!(out, " {{");
2047 let _ = writeln!(
2048 out,
2049 " var _cparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {field_expr}, .{{}});"
2050 );
2051 let _ = writeln!(out, " defer _cparse.deinit();");
2052 let _ = writeln!(
2053 out,
2054 " try testing.expectEqual({n}, _cparse.value.array.items.len);"
2055 );
2056 let _ = writeln!(out, " }}");
2057 }
2058 }
2059 }
2060 }
2061 "is_true" => {
2062 let _ = writeln!(out, " try testing.expect({field_expr});");
2063 }
2064 "is_false" => {
2065 let _ = writeln!(out, " try testing.expect(!{field_expr});");
2066 }
2067 "not_error" => {
2068 }
2070 "error" => {
2071 }
2073 "greater_than" => {
2074 if let Some(val) = &assertion.value {
2075 let zig_val = json_to_zig(val);
2076 let _ = writeln!(out, " try testing.expect({field_expr} > {zig_val});");
2077 }
2078 }
2079 "less_than" => {
2080 if let Some(val) = &assertion.value {
2081 let zig_val = json_to_zig(val);
2082 let _ = writeln!(out, " try testing.expect({field_expr} < {zig_val});");
2083 }
2084 }
2085 "greater_than_or_equal" => {
2086 if let Some(val) = &assertion.value {
2087 let zig_val = json_to_zig(val);
2088 let _ = writeln!(out, " try testing.expect({field_expr} >= {zig_val});");
2089 }
2090 }
2091 "less_than_or_equal" => {
2092 if let Some(val) = &assertion.value {
2093 let zig_val = json_to_zig(val);
2094 let _ = writeln!(out, " try testing.expect({field_expr} <= {zig_val});");
2095 }
2096 }
2097 "contains_any" => {
2098 if let Some(values) = &assertion.values {
2100 let string_values: Vec<String> = values
2101 .iter()
2102 .filter_map(|v| {
2103 if let serde_json::Value::String(s) = v {
2104 Some(format!(
2105 "std.mem.indexOf(u8, {field_expr}, \"{}\") != null",
2106 escape_zig(s)
2107 ))
2108 } else {
2109 None
2110 }
2111 })
2112 .collect();
2113 if !string_values.is_empty() {
2114 let condition = string_values.join(" or\n ");
2115 let _ = writeln!(out, " try testing.expect(\n {condition}\n );");
2116 }
2117 }
2118 }
2119 "matches_regex" => {
2120 let _ = writeln!(out, " // regex match not yet implemented for Zig");
2121 }
2122 "method_result" => {
2123 let _ = writeln!(out, " // method_result assertions not yet implemented for Zig");
2124 }
2125 other => {
2126 panic!("Zig e2e generator: unsupported assertion type: {other}");
2127 }
2128 }
2129}
2130
2131fn json_to_zig(value: &serde_json::Value) -> String {
2133 match value {
2134 serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
2135 serde_json::Value::Bool(b) => b.to_string(),
2136 serde_json::Value::Number(n) => n.to_string(),
2137 serde_json::Value::Null => "null".to_string(),
2138 serde_json::Value::Array(arr) => {
2139 let items: Vec<String> = arr.iter().map(json_to_zig).collect();
2140 format!("&.{{{}}}", items.join(", "))
2141 }
2142 serde_json::Value::Object(_) => {
2143 let json_str = serde_json::to_string(value).unwrap_or_default();
2144 format!("\"{}\"", escape_zig(&json_str))
2145 }
2146 }
2147}