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::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
76 let field_resolver = FieldResolver::new(
78 &e2e_config.fields,
79 &e2e_config.fields_optional,
80 &e2e_config.result_fields,
81 &e2e_config.fields_array,
82 &e2e_config.fields_method_calls,
83 );
84
85 let mut test_filenames: Vec<String> = Vec::new();
87 for group in groups {
88 let active: Vec<&Fixture> = group
89 .fixtures
90 .iter()
91 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
92 .collect();
93
94 if active.is_empty() {
95 continue;
96 }
97
98 let filename = format!("{}_test.zig", sanitize_filename(&group.category));
99 test_filenames.push(filename.clone());
100 let content = render_test_file(
101 &group.category,
102 &active,
103 e2e_config,
104 &function_name,
105 result_var,
106 &e2e_config.call.args,
107 &field_resolver,
108 &e2e_config.fields_enum,
109 &module_name,
110 );
111 files.push(GeneratedFile {
112 path: output_base.join("src").join(filename),
113 content,
114 generated_header: true,
115 });
116 }
117
118 files.insert(
120 files
121 .iter()
122 .position(|f| f.path.file_name().is_some_and(|n| n == "build.zig.zon"))
123 .unwrap_or(1),
124 GeneratedFile {
125 path: output_base.join("build.zig"),
126 content: render_build_zig(
127 &test_filenames,
128 &pkg_name,
129 &module_name,
130 &config.ffi_lib_name(),
131 &config.ffi_crate_path(),
132 &e2e_config.test_documents_relative_from(0),
133 ),
134 generated_header: false,
135 },
136 );
137
138 Ok(files)
139 }
140
141 fn language_name(&self) -> &'static str {
142 "zig"
143 }
144}
145
146fn render_build_zig_zon(pkg_name: &str, pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
151 let dep_block = match dep_mode {
152 crate::config::DependencyMode::Registry => {
153 format!(
155 r#".{{
156 .url = "https://registry.example.com/{pkg_name}/v0.1.0.tar.gz",
157 .hash = "0000000000000000000000000000000000000000000000000000000000000000",
158 }}"#
159 )
160 }
161 crate::config::DependencyMode::Local => {
162 format!(r#".{{ .path = "{pkg_path}" }}"#)
163 }
164 };
165
166 let min_zig = toolchain::MIN_ZIG_VERSION;
167 let name_bytes: &[u8] = b"e2e_zig";
169 let mut crc: u32 = 0xffff_ffff;
170 for byte in name_bytes {
171 crc ^= *byte as u32;
172 for _ in 0..8 {
173 let mask = (crc & 1).wrapping_neg();
174 crc = (crc >> 1) ^ (0xedb8_8320 & mask);
175 }
176 }
177 let name_crc: u32 = !crc;
178 let mut id: u32 = 0x811c_9dc5;
179 for byte in name_bytes {
180 id ^= *byte as u32;
181 id = id.wrapping_mul(0x0100_0193);
182 }
183 if id == 0 || id == 0xffff_ffff {
184 id = 0x1;
185 }
186 let fingerprint: u64 = ((name_crc as u64) << 32) | (id as u64);
187 format!(
188 r#".{{
189 .name = .e2e_zig,
190 .version = "0.1.0",
191 .fingerprint = 0x{fingerprint:016x},
192 .minimum_zig_version = "{min_zig}",
193 .dependencies = .{{
194 .{pkg_name} = {dep_block},
195 }},
196 .paths = .{{
197 "build.zig",
198 "build.zig.zon",
199 "src",
200 }},
201}}
202"#
203 )
204}
205
206fn render_build_zig(
207 test_filenames: &[String],
208 pkg_name: &str,
209 module_name: &str,
210 ffi_lib_name: &str,
211 ffi_crate_path: &str,
212 test_documents_path: &str,
213) -> String {
214 if test_filenames.is_empty() {
215 return r#"const std = @import("std");
216
217pub fn build(b: *std.Build) void {
218 const target = b.standardTargetOptions(.{});
219 const optimize = b.standardOptimizeOption(.{});
220
221 const test_step = b.step("test", "Run tests");
222}
223"#
224 .to_string();
225 }
226
227 let mut content = String::from("const std = @import(\"std\");\n\npub fn build(b: *std.Build) void {\n");
237 content.push_str(" const target = b.standardTargetOptions(.{});\n");
238 content.push_str(" const optimize = b.standardOptimizeOption(.{});\n");
239 content.push_str(" const test_step = b.step(\"test\", \"Run tests\");\n");
240 let _ = writeln!(
241 content,
242 " const ffi_path = b.option([]const u8, \"ffi_path\", \"Path to directory containing lib{ffi_lib_name}\") orelse \"../../target/debug\";"
243 );
244 let _ = writeln!(
245 content,
246 " const ffi_include = b.option([]const u8, \"ffi_include_path\", \"Path to directory containing FFI header\") orelse \"{ffi_crate_path}/include\";"
247 );
248 let _ = writeln!(content);
249 let _ = writeln!(
250 content,
251 " const {module_name}_module = b.addModule(\"{module_name}\", .{{"
252 );
253 let _ = writeln!(
254 content,
255 " .root_source_file = b.path(\"../../packages/zig/src/{pkg_name}.zig\"),"
256 );
257 content.push_str(" .target = target,\n");
258 content.push_str(" .optimize = optimize,\n");
259 content.push_str(" });\n");
260 let _ = writeln!(
261 content,
262 " {module_name}_module.addLibraryPath(.{{ .cwd_relative = ffi_path }});"
263 );
264 let _ = writeln!(
265 content,
266 " {module_name}_module.addIncludePath(.{{ .cwd_relative = ffi_include }});"
267 );
268 let _ = writeln!(
269 content,
270 " {module_name}_module.linkSystemLibrary(\"{ffi_lib_name}\", .{{}});"
271 );
272 let _ = writeln!(content);
273
274 for filename in test_filenames {
275 let test_name = filename.trim_end_matches("_test.zig");
277 content.push_str(&format!(" const {test_name}_module = b.createModule(.{{\n"));
278 content.push_str(&format!(" .root_source_file = b.path(\"src/{filename}\"),\n"));
279 content.push_str(" .target = target,\n");
280 content.push_str(" .optimize = optimize,\n");
281 content.push_str(" });\n");
282 content.push_str(&format!(
283 " {test_name}_module.addImport(\"{module_name}\", {module_name}_module);\n"
284 ));
285 content.push_str(&format!(" const {test_name}_tests = b.addTest(.{{\n"));
286 content.push_str(&format!(" .root_module = {test_name}_module,\n"));
287 content.push_str(" });\n");
288 content.push_str(&format!(
289 " const {test_name}_run = b.addRunArtifact({test_name}_tests);\n"
290 ));
291 content.push_str(&format!(
292 " {test_name}_run.setCwd(b.path(\"{test_documents_path}\"));\n"
293 ));
294 content.push_str(&format!(" test_step.dependOn(&{test_name}_run.step);\n\n"));
295 }
296
297 content.push_str("}\n");
298 content
299}
300
301struct ZigTestClientRenderer;
309
310impl client::TestClientRenderer for ZigTestClientRenderer {
311 fn language_name(&self) -> &'static str {
312 "zig"
313 }
314
315 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
316 if let Some(reason) = skip_reason {
317 let _ = writeln!(out, "test \"{fn_name}\" {{");
318 let _ = writeln!(out, " // {description}");
319 let _ = writeln!(out, " // skipped: {reason}");
320 let _ = writeln!(out, " return error.SkipZigTest;");
321 } else {
322 let _ = writeln!(out, "test \"{fn_name}\" {{");
323 let _ = writeln!(out, " // {description}");
324 }
325 }
326
327 fn render_test_close(&self, out: &mut String) {
328 let _ = writeln!(out, "}}");
329 }
330
331 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
332 let method = ctx.method.to_uppercase();
333 let fixture_id = ctx.path.trim_start_matches("/fixtures/");
334
335 let _ = writeln!(out, " var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
336 let _ = writeln!(out, " defer _ = gpa.deinit();");
337 let _ = writeln!(out, " const allocator = gpa.allocator();");
338
339 let _ = writeln!(out, " var url_buf: [512]u8 = undefined;");
340 let _ = writeln!(
341 out,
342 " 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\"}});"
343 );
344
345 if !ctx.headers.is_empty() {
347 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
348 header_pairs.sort_by_key(|(k, _)| k.as_str());
349 let _ = writeln!(out, " const headers = [_]std.http.Header{{");
350 for (k, v) in &header_pairs {
351 let ek = escape_zig(k);
352 let ev = escape_zig(v);
353 let _ = writeln!(out, " .{{ .name = \"{ek}\", .value = \"{ev}\" }},");
354 }
355 let _ = writeln!(out, " }};");
356 }
357
358 if let Some(body) = ctx.body {
360 let json_str = serde_json::to_string(body).unwrap_or_default();
361 let escaped = escape_zig(&json_str);
362 let _ = writeln!(out, " const body_bytes: []const u8 = \"{escaped}\";");
363 }
364
365 let headers_arg = if ctx.headers.is_empty() { "&.{}" } else { "&headers" };
366 let has_body = ctx.body.is_some();
367
368 let _ = writeln!(
369 out,
370 " var http_client = std.http.Client{{ .allocator = allocator }};"
371 );
372 let _ = writeln!(out, " defer http_client.deinit();");
373 let _ = writeln!(out, " var response_body = std.ArrayList(u8).init(allocator);");
374 let _ = writeln!(out, " defer response_body.deinit();");
375
376 let method_zig = match method.as_str() {
377 "GET" => ".GET",
378 "POST" => ".POST",
379 "PUT" => ".PUT",
380 "DELETE" => ".DELETE",
381 "PATCH" => ".PATCH",
382 "HEAD" => ".HEAD",
383 "OPTIONS" => ".OPTIONS",
384 _ => ".GET",
385 };
386
387 let payload_field = if has_body { ", .payload = body_bytes" } else { "" };
388 let _ = writeln!(
389 out,
390 " const {rv} = try http_client.fetch(.{{ .location = .{{ .url = url }}, .method = {method_zig}, .extra_headers = {headers_arg}{payload_field}, .response_storage = .{{ .dynamic = &response_body }} }});",
391 rv = ctx.response_var,
392 );
393 }
394
395 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
396 let _ = writeln!(
397 out,
398 " try testing.expectEqual(@as(u10, {status}), @intFromEnum({response_var}.status));"
399 );
400 }
401
402 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
403 let ename = escape_zig(&name.to_lowercase());
404 match expected {
405 "<<present>>" => {
406 let _ = writeln!(
407 out,
408 " // assert header '{ename}' is present (header inspection not yet implemented)"
409 );
410 }
411 "<<absent>>" => {
412 let _ = writeln!(
413 out,
414 " // assert header '{ename}' is absent (header inspection not yet implemented)"
415 );
416 }
417 "<<uuid>>" => {
418 let _ = writeln!(
419 out,
420 " // assert header '{ename}' matches UUID pattern (header inspection not yet implemented)"
421 );
422 }
423 exact => {
424 let evalue = escape_zig(exact);
425 let _ = writeln!(
426 out,
427 " // assert header '{ename}' == \"{evalue}\" (header inspection not yet implemented)"
428 );
429 }
430 }
431 }
432
433 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
434 let json_str = serde_json::to_string(expected).unwrap_or_default();
435 let escaped = escape_zig(&json_str);
436 let _ = writeln!(
437 out,
438 " try testing.expectEqualStrings(\"{escaped}\", response_body.items);"
439 );
440 }
441
442 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
443 if let Some(obj) = expected.as_object() {
444 for (key, val) in obj {
445 let ekey = escape_zig(key);
446 let eval = escape_zig(&serde_json::to_string(val).unwrap_or_default());
447 let _ = writeln!(
448 out,
449 " // assert body contains field \"{ekey}\" = \"{eval}\" (partial JSON not yet implemented)"
450 );
451 }
452 }
453 }
454
455 fn render_assert_validation_errors(
456 &self,
457 out: &mut String,
458 _response_var: &str,
459 errors: &[crate::fixture::ValidationErrorExpectation],
460 ) {
461 for ve in errors {
462 let loc = ve.loc.join(".");
463 let escaped_loc = escape_zig(&loc);
464 let escaped_msg = escape_zig(&ve.msg);
465 let _ = writeln!(
466 out,
467 " // assert validation error at \"{escaped_loc}\": \"{escaped_msg}\" (not yet implemented)"
468 );
469 }
470 }
471}
472
473fn render_http_test_case(out: &mut String, fixture: &Fixture) {
478 client::http_call::render_http_test(out, &ZigTestClientRenderer, fixture);
479}
480
481#[allow(clippy::too_many_arguments)]
486fn render_test_file(
487 category: &str,
488 fixtures: &[&Fixture],
489 e2e_config: &E2eConfig,
490 function_name: &str,
491 result_var: &str,
492 args: &[crate::config::ArgMapping],
493 field_resolver: &FieldResolver,
494 enum_fields: &HashSet<String>,
495 module_name: &str,
496) -> String {
497 let mut out = String::new();
498 out.push_str(&hash::header(CommentStyle::DoubleSlash));
499 let _ = writeln!(out, "const std = @import(\"std\");");
500 let _ = writeln!(out, "const testing = std.testing;");
501 let _ = writeln!(out, "const {module_name} = @import(\"{module_name}\");");
502 let _ = writeln!(out);
503
504 let _ = writeln!(out, "// E2e tests for category: {category}");
505 let _ = writeln!(out);
506
507 for fixture in fixtures {
508 if fixture.http.is_some() {
509 render_http_test_case(&mut out, fixture);
510 } else {
511 render_test_fn(
512 &mut out,
513 fixture,
514 e2e_config,
515 function_name,
516 result_var,
517 args,
518 field_resolver,
519 enum_fields,
520 module_name,
521 );
522 }
523 let _ = writeln!(out);
524 }
525
526 out
527}
528
529#[allow(clippy::too_many_arguments)]
530fn render_test_fn(
531 out: &mut String,
532 fixture: &Fixture,
533 e2e_config: &E2eConfig,
534 _function_name: &str,
535 _result_var: &str,
536 _args: &[crate::config::ArgMapping],
537 field_resolver: &FieldResolver,
538 enum_fields: &HashSet<String>,
539 module_name: &str,
540) {
541 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
543 let lang = "zig";
544 let call_overrides = call_config.overrides.get(lang);
545 let function_name = call_overrides
546 .and_then(|o| o.function.as_ref())
547 .cloned()
548 .unwrap_or_else(|| call_config.function.clone());
549 let result_var = &call_config.result_var;
550 let args = &call_config.args;
551 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
556 e2e_config
557 .call
558 .overrides
559 .get(lang)
560 .and_then(|o| o.client_factory.as_deref())
561 });
562
563 let result_is_json_struct = call_overrides.is_some_and(|o| o.result_is_json_struct) || client_factory.is_some();
573
574 let test_name = fixture.id.to_snake_case();
575 let description = &fixture.description;
576 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
577
578 let (setup_lines, args_str, setup_needs_gpa) = build_args_and_setup(&fixture.input, args, &fixture.id, module_name);
579 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
585 let args_str = if extra_args.is_empty() {
586 args_str
587 } else if args_str.is_empty() {
588 extra_args.join(", ")
589 } else {
590 format!("{args_str}, {}", extra_args.join(", "))
591 };
592
593 let any_happy_emits_code = fixture
596 .assertions
597 .iter()
598 .any(|a| assertion_emits_code(a, field_resolver));
599 let any_non_error_emits_code = fixture
600 .assertions
601 .iter()
602 .filter(|a| a.assertion_type != "error")
603 .any(|a| assertion_emits_code(a, field_resolver));
604
605 let has_streaming_virtual_assertions = fixture.assertions.iter().any(|a| {
607 a.field
608 .as_ref()
609 .is_some_and(|f| !f.is_empty() && is_streaming_virtual_field(f))
610 });
611 let is_stream_fn = function_name.contains("stream");
612 let uses_streaming_virtual_path =
613 result_is_json_struct && has_streaming_virtual_assertions && is_stream_fn && client_factory.is_some();
614 let streaming_path_has_non_streaming = uses_streaming_virtual_path
616 && fixture.assertions.iter().any(|a| {
617 !a.field
618 .as_ref()
619 .is_some_and(|f| !f.is_empty() && is_streaming_virtual_field(f))
620 && !matches!(a.assertion_type.as_str(), "not_error" | "error")
621 && a.field
622 .as_ref()
623 .is_some_and(|f| !f.is_empty() && field_resolver.is_valid_for_result(f))
624 });
625
626 let _ = writeln!(out, "test \"{test_name}\" {{");
627 let _ = writeln!(out, " // {description}");
628
629 let needs_gpa = setup_needs_gpa
637 || streaming_path_has_non_streaming
638 || (!uses_streaming_virtual_path && result_is_json_struct && !expects_error && any_happy_emits_code)
639 || (!uses_streaming_virtual_path && result_is_json_struct && expects_error && any_non_error_emits_code);
640 if needs_gpa {
641 let _ = writeln!(out, " var gpa: std.heap.DebugAllocator(.{{}}) = .init;");
642 let _ = writeln!(out, " defer _ = gpa.deinit();");
643 let _ = writeln!(out, " const allocator = gpa.allocator();");
644 let _ = writeln!(out);
645 }
646
647 for line in &setup_lines {
648 let _ = writeln!(out, " {line}");
649 }
650
651 let call_prefix = if let Some(factory) = client_factory {
656 let fixture_id = &fixture.id;
657 let _ = writeln!(
658 out,
659 " 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);"
660 );
661 let _ = writeln!(out, " defer std.heap.c_allocator.free(_mock_url);");
662 let _ = writeln!(
663 out,
664 " var _client = try {module_name}.{factory}(\"test-key\", _mock_url, null, null, null);"
665 );
666 let _ = writeln!(out, " defer _client.free();");
667 "_client".to_string()
668 } else {
669 module_name.to_string()
670 };
671
672 if expects_error {
673 if result_is_json_struct {
677 let _ = writeln!(
678 out,
679 " const _result_json = {call_prefix}.{function_name}({args_str}) catch {{"
680 );
681 } else {
682 let _ = writeln!(
683 out,
684 " const result = {call_prefix}.{function_name}({args_str}) catch {{"
685 );
686 }
687 let _ = writeln!(out, " try testing.expect(true); // Error occurred as expected");
688 let _ = writeln!(out, " return;");
689 let _ = writeln!(out, " }};");
690 let any_emits_code = fixture
694 .assertions
695 .iter()
696 .filter(|a| a.assertion_type != "error")
697 .any(|a| assertion_emits_code(a, field_resolver));
698 if result_is_json_struct && any_emits_code {
699 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
700 let _ = writeln!(
701 out,
702 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
703 );
704 let _ = writeln!(out, " defer _parsed.deinit();");
705 let _ = writeln!(out, " const {result_var} = &_parsed.value;");
706 let _ = writeln!(out, " // Perform success assertions if any");
707 for assertion in &fixture.assertions {
708 if assertion.assertion_type != "error" {
709 render_json_assertion(out, assertion, result_var, field_resolver);
710 }
711 }
712 } else if result_is_json_struct {
713 let _ = writeln!(out, " _ = _result_json;");
714 } else if any_emits_code {
715 let _ = writeln!(out, " // Perform success assertions if any");
716 for assertion in &fixture.assertions {
717 if assertion.assertion_type != "error" {
718 render_assertion(out, assertion, result_var, field_resolver, enum_fields);
719 }
720 }
721 } else {
722 let _ = writeln!(out, " _ = result;");
723 }
724 } else if fixture.assertions.is_empty() {
725 if result_is_json_struct {
727 let _ = writeln!(
728 out,
729 " const _result_json = try {call_prefix}.{function_name}({args_str});"
730 );
731 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
732 } else {
733 let _ = writeln!(out, " _ = try {call_prefix}.{function_name}({args_str});");
734 }
735 } else {
736 let any_emits_code = fixture
740 .assertions
741 .iter()
742 .any(|a| assertion_emits_code(a, field_resolver));
743 if result_is_json_struct {
744 if uses_streaming_virtual_path {
748 let _ = writeln!(
751 out,
752 " const _req_z = try std.heap.c_allocator.dupeZ(u8, {args_str});"
753 );
754 let _ = writeln!(out, " defer std.heap.c_allocator.free(_req_z);");
755 let _ = writeln!(
756 out,
757 " const _req_handle = {module_name}.c.literllm_chat_completion_request_from_json(_req_z.ptr);"
758 );
759 let _ = writeln!(
760 out,
761 " defer {module_name}.c.literllm_chat_completion_request_free(_req_handle);"
762 );
763 let _ = writeln!(
764 out,
765 " const _stream_handle = {module_name}.c.literllm_default_client_chat_stream_start(@as(*{module_name}.c.LITERLLMDefaultClient, @ptrCast(_client._handle)), _req_handle);"
766 );
767 let _ = writeln!(out, " if (_stream_handle == null) return error.StreamStartFailed;");
768 let _ = writeln!(
769 out,
770 " defer {module_name}.c.literllm_default_client_chat_stream_free(_stream_handle);"
771 );
772 if let Some(snip) = StreamingFieldResolver::collect_snippet("zig", "_stream_handle", "chunks") {
774 out.push_str(" ");
775 out.push_str(&snip);
776 out.push('\n');
777 }
778 if streaming_path_has_non_streaming {
781 let _ = writeln!(
782 out,
783 " const _result_json = if (chunks.items.len > 0) chunks.items[chunks.items.len - 1] else &[_]u8{{}};"
784 );
785 let _ = writeln!(
786 out,
787 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
788 );
789 let _ = writeln!(out, " defer _parsed.deinit();");
790 let _ = writeln!(out, " const {result_var} = &_parsed.value;");
791 }
792 for assertion in &fixture.assertions {
793 render_json_assertion(out, assertion, result_var, field_resolver);
794 }
795 } else {
796 let _ = writeln!(
798 out,
799 " const _result_json = try {call_prefix}.{function_name}({args_str});"
800 );
801 let _ = writeln!(out, " defer std.heap.c_allocator.free(_result_json);");
802 if any_emits_code {
803 let _ = writeln!(
804 out,
805 " var _parsed = try std.json.parseFromSlice(std.json.Value, allocator, _result_json, .{{}});"
806 );
807 let _ = writeln!(out, " defer _parsed.deinit();");
808 let _ = writeln!(out, " const {result_var} = &_parsed.value;");
809 for assertion in &fixture.assertions {
810 render_json_assertion(out, assertion, result_var, field_resolver);
811 }
812 }
813 }
814 } else if any_emits_code {
815 let _ = writeln!(
816 out,
817 " const {result_var} = try {call_prefix}.{function_name}({args_str});"
818 );
819 for assertion in &fixture.assertions {
820 render_assertion(out, assertion, result_var, field_resolver, enum_fields);
821 }
822 } else {
823 let _ = writeln!(out, " _ = try {call_prefix}.{function_name}({args_str});");
824 }
825 }
826
827 let _ = writeln!(out, "}}");
828}
829
830const FORMAT_METADATA_VARIANTS: &[&str] = &[
848 "pdf",
849 "docx",
850 "excel",
851 "email",
852 "pptx",
853 "archive",
854 "image",
855 "xml",
856 "text",
857 "html",
858 "ocr",
859 "csv",
860 "bibtex",
861 "citation",
862 "fiction_book",
863 "dbf",
864 "jats",
865 "epub",
866 "pst",
867 "code",
868];
869
870fn json_path_expr(result_var: &str, field_path: &str) -> String {
871 let segments: Vec<&str> = field_path.split('.').collect();
872 let mut expr = result_var.to_string();
873 let mut prev_seg: Option<&str> = None;
874 for seg in &segments {
875 if prev_seg == Some("format") && FORMAT_METADATA_VARIANTS.contains(seg) {
880 prev_seg = Some(seg);
881 continue;
882 }
883 if let Some(key) = seg.strip_suffix("[]") {
887 expr = format!("{expr}.object.get(\"{key}\").?.array.items[0]");
888 } else if let Some(bracket_pos) = seg.find('[') {
889 if let Some(end_pos) = seg.find(']') {
890 if end_pos > bracket_pos + 1 && end_pos == seg.len() - 1 {
891 let key = &seg[..bracket_pos];
892 let idx = &seg[bracket_pos + 1..end_pos];
893 if idx.chars().all(|c| c.is_ascii_digit()) {
894 expr = format!("{expr}.object.get(\"{key}\").?.array.items[{idx}]");
895 prev_seg = Some(seg);
896 continue;
897 }
898 }
899 }
900 expr = format!("{expr}.object.get(\"{seg}\").?");
901 } else {
902 expr = format!("{expr}.object.get(\"{seg}\").?");
903 }
904 prev_seg = Some(seg);
905 }
906 expr
907}
908
909fn render_json_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
914 if let Some(f) = &assertion.field {
916 if !f.is_empty() && is_streaming_virtual_field(f) {
917 if let Some(expr) = StreamingFieldResolver::accessor(f, "zig", "chunks") {
918 match assertion.assertion_type.as_str() {
919 "count_min" => {
920 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
921 let _ = writeln!(out, " try testing.expect({expr}.len >= {n});");
922 }
923 }
924 "count_equals" => {
925 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
926 let _ = writeln!(out, " try testing.expectEqual(@as(usize, {n}), {expr}.len);");
927 }
928 }
929 "equals" => {
930 if let Some(serde_json::Value::String(s)) = &assertion.value {
931 let escaped = escape_zig(s);
932 let _ = writeln!(out, " try testing.expectEqualStrings(\"{escaped}\", {expr});");
933 } else if let Some(v) = &assertion.value {
934 let zig_val = json_to_zig(v);
935 let _ = writeln!(out, " try testing.expectEqual({zig_val}, {expr});");
936 }
937 }
938 "not_empty" => {
939 let _ = writeln!(out, " try testing.expect({expr}.len > 0);");
940 }
941 "is_true" => {
942 let _ = writeln!(out, " try testing.expect({expr});");
943 }
944 "is_false" => {
945 let _ = writeln!(out, " try testing.expect(!{expr});");
946 }
947 _ => {
948 let atype = &assertion.assertion_type;
949 let _ = writeln!(
950 out,
951 " // streaming virtual field '{f}' assertion '{atype}' not implemented for zig"
952 );
953 }
954 }
955 }
956 return;
957 }
958 }
959
960 if let Some(f) = &assertion.field {
962 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
963 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
964 return;
965 }
966 }
967 if matches!(assertion.assertion_type.as_str(), "not_error" | "error") {
969 return;
970 }
971
972 let raw_field_path = assertion.field.as_deref().unwrap_or("").trim();
973 let field_path = if raw_field_path.is_empty() {
974 raw_field_path.to_string()
975 } else {
976 field_resolver.resolve(raw_field_path).to_string()
977 };
978 let field_path = field_path.trim();
979
980 let (field_path_for_expr, is_length_access) = if let Some(parent) = field_path.strip_suffix(".length") {
982 (parent, true)
983 } else {
984 (field_path, false)
985 };
986
987 let field_expr = if field_path_for_expr.is_empty() {
988 result_var.to_string()
989 } else {
990 json_path_expr(result_var, field_path_for_expr)
991 };
992
993 let zig_val = match &assertion.value {
995 Some(serde_json::Value::String(s)) => format!("\"{}\"", escape_zig(s)),
996 _ => String::new(),
997 };
998 let is_string_val = matches!(&assertion.value, Some(serde_json::Value::String(_)));
999 let is_bool_val = matches!(&assertion.value, Some(serde_json::Value::Bool(_)));
1000 let bool_val = match &assertion.value {
1001 Some(serde_json::Value::Bool(b)) if *b => "true",
1002 _ => "false",
1003 };
1004 let is_null_val = matches!(&assertion.value, Some(serde_json::Value::Null));
1005 let n = assertion.value.as_ref().map(json_to_zig).unwrap_or_default();
1006 let has_n = assertion.value.as_ref().is_some_and(|v| v.is_number() || v.is_u64());
1007 let is_float_val = matches!(&assertion.value, Some(serde_json::Value::Number(n)) if !n.is_i64() && !n.is_u64());
1012 let values_list: Vec<String> = assertion
1013 .values
1014 .as_deref()
1015 .unwrap_or_default()
1016 .iter()
1017 .filter_map(|v| {
1018 if let serde_json::Value::String(s) = v {
1019 Some(format!("\"{}\"", escape_zig(s)))
1020 } else {
1021 None
1022 }
1023 })
1024 .collect();
1025
1026 let rendered = crate::template_env::render(
1027 "zig/json_assertion.jinja",
1028 minijinja::context! {
1029 assertion_type => assertion.assertion_type.as_str(),
1030 field_expr => field_expr,
1031 is_length_access => is_length_access,
1032 zig_val => zig_val,
1033 is_string_val => is_string_val,
1034 is_bool_val => is_bool_val,
1035 bool_val => bool_val,
1036 is_null_val => is_null_val,
1037 n => n,
1038 has_n => has_n,
1039 is_float_val => is_float_val,
1040 values_list => values_list,
1041 },
1042 );
1043 out.push_str(&rendered);
1044}
1045
1046fn assertion_emits_code(assertion: &Assertion, field_resolver: &FieldResolver) -> bool {
1049 if let Some(f) = &assertion.field {
1050 if !f.is_empty() && is_streaming_virtual_field(f) {
1051 } else if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1054 return false;
1055 }
1056 }
1057 matches!(
1058 assertion.assertion_type.as_str(),
1059 "equals"
1060 | "contains"
1061 | "contains_all"
1062 | "not_contains"
1063 | "not_empty"
1064 | "is_empty"
1065 | "starts_with"
1066 | "ends_with"
1067 | "min_length"
1068 | "max_length"
1069 | "count_min"
1070 | "count_equals"
1071 | "is_true"
1072 | "is_false"
1073 | "greater_than"
1074 | "less_than"
1075 | "greater_than_or_equal"
1076 | "less_than_or_equal"
1077 | "contains_any"
1078 )
1079}
1080
1081fn build_args_and_setup(
1086 input: &serde_json::Value,
1087 args: &[crate::config::ArgMapping],
1088 fixture_id: &str,
1089 _module_name: &str,
1090) -> (Vec<String>, String, bool) {
1091 if args.is_empty() {
1092 return (Vec::new(), String::new(), false);
1093 }
1094
1095 let mut setup_lines: Vec<String> = Vec::new();
1096 let mut parts: Vec<String> = Vec::new();
1097 let mut setup_needs_gpa = false;
1098
1099 for arg in args {
1100 if arg.arg_type == "mock_url" {
1101 let name = arg.name.clone();
1102 let id_upper = fixture_id.to_uppercase();
1103 setup_lines.push(format!(
1104 "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\"}});"
1105 ));
1106 setup_lines.push(format!("defer allocator.free({name});"));
1107 parts.push(name);
1108 setup_needs_gpa = true;
1109 continue;
1110 }
1111
1112 if arg.arg_type == "handle" {
1115 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1116 let json_str = match input.get(field) {
1117 Some(serde_json::Value::Null) | None => "null".to_string(),
1118 Some(v) => format!("\"{}\"", escape_zig(&serde_json::to_string(v).unwrap_or_default())),
1119 };
1120 parts.push(json_str);
1121 continue;
1122 }
1123
1124 if arg.name == "config" && arg.arg_type == "json_object" {
1131 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1132 let json_str = match input.get(field) {
1133 Some(serde_json::Value::Null) | None => "{}".to_string(),
1134 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1135 };
1136 parts.push(format!("\"{}\"", escape_zig(&json_str)));
1137 continue;
1138 }
1139
1140 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1141 let val = input.get(field);
1142 match val {
1143 None | Some(serde_json::Value::Null) if arg.optional => {
1144 parts.push("null".to_string());
1147 }
1148 None | Some(serde_json::Value::Null) => {
1149 let default_val = match arg.arg_type.as_str() {
1150 "string" => "\"\"".to_string(),
1151 "int" | "integer" => "0".to_string(),
1152 "float" | "number" => "0.0".to_string(),
1153 "bool" | "boolean" => "false".to_string(),
1154 "json_object" => "\"{}\"".to_string(),
1155 _ => "null".to_string(),
1156 };
1157 parts.push(default_val);
1158 }
1159 Some(v) => {
1160 if arg.arg_type == "json_object" {
1165 let json_str = serde_json::to_string(v).unwrap_or_default();
1166 parts.push(format!("\"{}\"", escape_zig(&json_str)));
1167 } else if arg.arg_type == "bytes" {
1168 if let serde_json::Value::String(path) = v {
1173 let var_name = format!("{}_bytes", arg.name);
1174 let epath = escape_zig(path);
1175 setup_lines.push(format!(
1176 "const {var_name} = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, \"{epath}\", std.heap.c_allocator, .unlimited);"
1177 ));
1178 setup_lines.push(format!("defer std.heap.c_allocator.free({var_name});"));
1179 parts.push(var_name);
1180 } else {
1181 parts.push(json_to_zig(v));
1182 }
1183 } else {
1184 parts.push(json_to_zig(v));
1185 }
1186 }
1187 }
1188 }
1189
1190 (setup_lines, parts.join(", "), setup_needs_gpa)
1191}
1192
1193fn render_assertion(
1194 out: &mut String,
1195 assertion: &Assertion,
1196 result_var: &str,
1197 field_resolver: &FieldResolver,
1198 enum_fields: &HashSet<String>,
1199) {
1200 if let Some(f) = &assertion.field {
1202 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1203 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
1204 return;
1205 }
1206 }
1207
1208 let _field_is_enum = assertion
1210 .field
1211 .as_deref()
1212 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1213
1214 let field_expr = match &assertion.field {
1215 Some(f) if !f.is_empty() => field_resolver.accessor(f, "zig", result_var),
1216 _ => result_var.to_string(),
1217 };
1218
1219 match assertion.assertion_type.as_str() {
1220 "equals" => {
1221 if let Some(expected) = &assertion.value {
1222 let zig_val = json_to_zig(expected);
1223 let _ = writeln!(out, " try testing.expectEqual({zig_val}, {field_expr});");
1224 }
1225 }
1226 "contains" => {
1227 if let Some(expected) = &assertion.value {
1228 let zig_val = json_to_zig(expected);
1229 let _ = writeln!(
1230 out,
1231 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1232 );
1233 }
1234 }
1235 "contains_all" => {
1236 if let Some(values) = &assertion.values {
1237 for val in values {
1238 let zig_val = json_to_zig(val);
1239 let _ = writeln!(
1240 out,
1241 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) != null);"
1242 );
1243 }
1244 }
1245 }
1246 "not_contains" => {
1247 if let Some(expected) = &assertion.value {
1248 let zig_val = json_to_zig(expected);
1249 let _ = writeln!(
1250 out,
1251 " try testing.expect(std.mem.indexOf(u8, {field_expr}, {zig_val}) == null);"
1252 );
1253 }
1254 }
1255 "not_empty" => {
1256 let _ = writeln!(out, " try testing.expect({field_expr}.len > 0);");
1257 }
1258 "is_empty" => {
1259 let _ = writeln!(out, " try testing.expect({field_expr}.len == 0);");
1260 }
1261 "starts_with" => {
1262 if let Some(expected) = &assertion.value {
1263 let zig_val = json_to_zig(expected);
1264 let _ = writeln!(
1265 out,
1266 " try testing.expect(std.mem.startsWith(u8, {field_expr}, {zig_val}));"
1267 );
1268 }
1269 }
1270 "ends_with" => {
1271 if let Some(expected) = &assertion.value {
1272 let zig_val = json_to_zig(expected);
1273 let _ = writeln!(
1274 out,
1275 " try testing.expect(std.mem.endsWith(u8, {field_expr}, {zig_val}));"
1276 );
1277 }
1278 }
1279 "min_length" => {
1280 if let Some(val) = &assertion.value {
1281 if let Some(n) = val.as_u64() {
1282 let _ = writeln!(out, " try testing.expect({field_expr}.len >= {n});");
1283 }
1284 }
1285 }
1286 "max_length" => {
1287 if let Some(val) = &assertion.value {
1288 if let Some(n) = val.as_u64() {
1289 let _ = writeln!(out, " try testing.expect({field_expr}.len <= {n});");
1290 }
1291 }
1292 }
1293 "count_min" => {
1294 if let Some(val) = &assertion.value {
1295 if let Some(n) = val.as_u64() {
1296 let _ = writeln!(out, " try testing.expect({field_expr}.len >= {n});");
1297 }
1298 }
1299 }
1300 "count_equals" => {
1301 if let Some(val) = &assertion.value {
1302 if let Some(n) = val.as_u64() {
1303 let has_field = assertion.field.as_deref().is_some_and(|f| !f.is_empty());
1307 if has_field {
1308 let _ = writeln!(out, " try testing.expectEqual({n}, {field_expr}.len);");
1309 } else {
1310 let _ = writeln!(out, " {{");
1311 let _ = writeln!(
1312 out,
1313 " var _cparse = try std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {field_expr}, .{{}});"
1314 );
1315 let _ = writeln!(out, " defer _cparse.deinit();");
1316 let _ = writeln!(
1317 out,
1318 " try testing.expectEqual({n}, _cparse.value.array.items.len);"
1319 );
1320 let _ = writeln!(out, " }}");
1321 }
1322 }
1323 }
1324 }
1325 "is_true" => {
1326 let _ = writeln!(out, " try testing.expect({field_expr});");
1327 }
1328 "is_false" => {
1329 let _ = writeln!(out, " try testing.expect(!{field_expr});");
1330 }
1331 "not_error" => {
1332 }
1334 "error" => {
1335 }
1337 "greater_than" => {
1338 if let Some(val) = &assertion.value {
1339 let zig_val = json_to_zig(val);
1340 let _ = writeln!(out, " try testing.expect({field_expr} > {zig_val});");
1341 }
1342 }
1343 "less_than" => {
1344 if let Some(val) = &assertion.value {
1345 let zig_val = json_to_zig(val);
1346 let _ = writeln!(out, " try testing.expect({field_expr} < {zig_val});");
1347 }
1348 }
1349 "greater_than_or_equal" => {
1350 if let Some(val) = &assertion.value {
1351 let zig_val = json_to_zig(val);
1352 let _ = writeln!(out, " try testing.expect({field_expr} >= {zig_val});");
1353 }
1354 }
1355 "less_than_or_equal" => {
1356 if let Some(val) = &assertion.value {
1357 let zig_val = json_to_zig(val);
1358 let _ = writeln!(out, " try testing.expect({field_expr} <= {zig_val});");
1359 }
1360 }
1361 "contains_any" => {
1362 if let Some(values) = &assertion.values {
1364 let string_values: Vec<String> = values
1365 .iter()
1366 .filter_map(|v| {
1367 if let serde_json::Value::String(s) = v {
1368 Some(format!(
1369 "std.mem.indexOf(u8, {field_expr}, \"{}\") != null",
1370 escape_zig(s)
1371 ))
1372 } else {
1373 None
1374 }
1375 })
1376 .collect();
1377 if !string_values.is_empty() {
1378 let condition = string_values.join(" or\n ");
1379 let _ = writeln!(out, " try testing.expect(\n {condition}\n );");
1380 }
1381 }
1382 }
1383 "matches_regex" => {
1384 let _ = writeln!(out, " // regex match not yet implemented for Zig");
1385 }
1386 "method_result" => {
1387 let _ = writeln!(out, " // method_result assertions not yet implemented for Zig");
1388 }
1389 other => {
1390 panic!("Zig e2e generator: unsupported assertion type: {other}");
1391 }
1392 }
1393}
1394
1395fn json_to_zig(value: &serde_json::Value) -> String {
1397 match value {
1398 serde_json::Value::String(s) => format!("\"{}\"", escape_zig(s)),
1399 serde_json::Value::Bool(b) => b.to_string(),
1400 serde_json::Value::Number(n) => n.to_string(),
1401 serde_json::Value::Null => "null".to_string(),
1402 serde_json::Value::Array(arr) => {
1403 let items: Vec<String> = arr.iter().map(json_to_zig).collect();
1404 format!("&.{{{}}}", items.join(", "))
1405 }
1406 serde_json::Value::Object(_) => {
1407 let json_str = serde_json::to_string(value).unwrap_or_default();
1408 format!("\"{}\"", escape_zig(&json_str))
1409 }
1410 }
1411}