1use crate::config::E2eConfig;
15use crate::escape::{escape_java as escape_swift_str, expand_fixture_templates, sanitize_filename, sanitize_ident};
16use crate::field_access::FieldResolver;
17use crate::fixture::{Assertion, Fixture, FixtureGroup, ValidationErrorExpectation};
18use alef_core::backend::GeneratedFile;
19use alef_core::config::ResolvedCrateConfig;
20use alef_core::hash::{self, CommentStyle};
21use alef_core::template_versions::toolchain;
22use anyhow::Result;
23use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
24use std::collections::HashSet;
25use std::fmt::Write as FmtWrite;
26use std::path::PathBuf;
27
28use super::E2eCodegen;
29use super::client;
30
31pub struct SwiftE2eCodegen;
33
34impl E2eCodegen for SwiftE2eCodegen {
35 fn generate(
36 &self,
37 groups: &[FixtureGroup],
38 e2e_config: &E2eConfig,
39 config: &ResolvedCrateConfig,
40 _type_defs: &[alef_core::ir::TypeDef],
41 ) -> Result<Vec<GeneratedFile>> {
42 let lang = self.language_name();
43 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
44
45 let mut files = Vec::new();
46
47 let call = &e2e_config.call;
49 let overrides = call.overrides.get(lang);
50 let function_name = overrides
51 .and_then(|o| o.function.as_ref())
52 .cloned()
53 .unwrap_or_else(|| call.function.clone());
54 let result_var = &call.result_var;
55 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
56
57 let swift_pkg = e2e_config.resolve_package("swift");
59 let pkg_name = swift_pkg
60 .as_ref()
61 .and_then(|p| p.name.as_ref())
62 .cloned()
63 .unwrap_or_else(|| config.name.to_upper_camel_case());
64 let pkg_path = swift_pkg
65 .as_ref()
66 .and_then(|p| p.path.as_ref())
67 .cloned()
68 .unwrap_or_else(|| "../../packages/swift".to_string());
69 let pkg_version = swift_pkg
70 .as_ref()
71 .and_then(|p| p.version.as_ref())
72 .cloned()
73 .or_else(|| config.resolved_version())
74 .unwrap_or_else(|| "0.1.0".to_string());
75
76 let module_name = pkg_name.as_str();
78
79 let registry_url = config
83 .try_github_repo()
84 .map(|repo| {
85 let base = repo.trim_end_matches('/').trim_end_matches(".git");
86 format!("{base}.git")
87 })
88 .unwrap_or_else(|_| format!("https://example.invalid/{module_name}.git"));
89
90 files.push(GeneratedFile {
93 path: output_base.join("Package.swift"),
94 content: render_package_swift(module_name, ®istry_url, &pkg_path, &pkg_version, e2e_config.dep_mode),
95 generated_header: false,
96 });
97
98 let tests_base = normalize_path(&output_base.join(&pkg_path));
112
113 let field_resolver = FieldResolver::new(
114 &e2e_config.fields,
115 &e2e_config.fields_optional,
116 &e2e_config.result_fields,
117 &e2e_config.fields_array,
118 &e2e_config.fields_method_calls,
119 );
120
121 let client_factory: Option<&str> = overrides.and_then(|o| o.client_factory.as_deref());
123
124 for group in groups {
126 let active: Vec<&Fixture> = group
127 .fixtures
128 .iter()
129 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
130 .collect();
131
132 if active.is_empty() {
133 continue;
134 }
135
136 let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
137 let filename = format!("{class_name}.swift");
138 let content = render_test_file(
139 &group.category,
140 &active,
141 e2e_config,
142 module_name,
143 &class_name,
144 &function_name,
145 result_var,
146 &e2e_config.call.args,
147 &field_resolver,
148 result_is_simple,
149 &e2e_config.fields_enum,
150 client_factory,
151 );
152 files.push(GeneratedFile {
153 path: tests_base
154 .join("Tests")
155 .join(format!("{module_name}Tests"))
156 .join(filename),
157 content,
158 generated_header: true,
159 });
160 }
161
162 Ok(files)
163 }
164
165 fn language_name(&self) -> &'static str {
166 "swift"
167 }
168}
169
170fn render_package_swift(
175 module_name: &str,
176 registry_url: &str,
177 pkg_path: &str,
178 pkg_version: &str,
179 dep_mode: crate::config::DependencyMode,
180) -> String {
181 let min_macos = toolchain::SWIFT_MIN_MACOS;
182
183 let (dep_block, product_dep) = match dep_mode {
187 crate::config::DependencyMode::Registry => {
188 let dep = format!(r#" .package(url: "{registry_url}", from: "{pkg_version}")"#);
189 let pkg_id = registry_url
190 .trim_end_matches('/')
191 .trim_end_matches(".git")
192 .split('/')
193 .next_back()
194 .unwrap_or(module_name);
195 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
196 (dep, prod)
197 }
198 crate::config::DependencyMode::Local => {
199 let dep = format!(r#" .package(path: "{pkg_path}")"#);
200 let pkg_id = pkg_path
201 .trim_end_matches('/')
202 .split('/')
203 .next_back()
204 .unwrap_or(module_name);
205 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
206 (dep, prod)
207 }
208 };
209 let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
212 format!(
215 r#"// swift-tools-version: 6.0
216import PackageDescription
217
218let package = Package(
219 name: "E2eSwift",
220 platforms: [
221 .macOS(.v{min_macos_major}),
222 .iOS(.v14),
223 ],
224 dependencies: [
225{dep_block},
226 ],
227 targets: [
228 .testTarget(
229 name: "{module_name}Tests",
230 dependencies: [{product_dep}]
231 ),
232 ]
233)
234"#
235 )
236}
237
238#[allow(clippy::too_many_arguments)]
239fn render_test_file(
240 category: &str,
241 fixtures: &[&Fixture],
242 e2e_config: &E2eConfig,
243 module_name: &str,
244 class_name: &str,
245 function_name: &str,
246 result_var: &str,
247 args: &[crate::config::ArgMapping],
248 field_resolver: &FieldResolver,
249 result_is_simple: bool,
250 enum_fields: &HashSet<String>,
251 client_factory: Option<&str>,
252) -> String {
253 let needs_chdir = fixtures.iter().any(|f| {
260 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
261 call_config
262 .args
263 .iter()
264 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
265 });
266
267 let mut out = String::new();
268 out.push_str(&hash::header(CommentStyle::DoubleSlash));
269 let _ = writeln!(out, "import XCTest");
270 let _ = writeln!(out, "import Foundation");
271 let _ = writeln!(out, "import {module_name}");
272 let _ = writeln!(out, "import RustBridge");
273 let _ = writeln!(out);
274 let _ = writeln!(out, "/// E2e tests for category: {category}.");
275 let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
276
277 if needs_chdir {
278 let _ = writeln!(out, " override class func setUp() {{");
286 let _ = writeln!(out, " super.setUp()");
287 let _ = writeln!(out, " let _testDocs = URL(fileURLWithPath: #filePath)");
288 let _ = writeln!(out, " .deletingLastPathComponent() // <Module>Tests/");
289 let _ = writeln!(out, " .deletingLastPathComponent() // Tests/");
290 let _ = writeln!(out, " .deletingLastPathComponent() // swift/");
291 let _ = writeln!(out, " .deletingLastPathComponent() // packages/");
292 let _ = writeln!(out, " .deletingLastPathComponent() // <repo root>");
293 let _ = writeln!(
294 out,
295 " .appendingPathComponent(\"{}\")",
296 e2e_config.test_documents_dir
297 );
298 let _ = writeln!(
299 out,
300 " if FileManager.default.fileExists(atPath: _testDocs.path) {{"
301 );
302 let _ = writeln!(
303 out,
304 " FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
305 );
306 let _ = writeln!(out, " }}");
307 let _ = writeln!(out, " }}");
308 let _ = writeln!(out);
309 }
310
311 for fixture in fixtures {
312 if fixture.is_http_test() {
313 render_http_test_method(&mut out, fixture);
314 } else {
315 render_test_method(
316 &mut out,
317 fixture,
318 e2e_config,
319 function_name,
320 result_var,
321 args,
322 field_resolver,
323 result_is_simple,
324 enum_fields,
325 client_factory,
326 );
327 }
328 let _ = writeln!(out);
329 }
330
331 let _ = writeln!(out, "}}");
332 out
333}
334
335struct SwiftTestClientRenderer;
342
343impl client::TestClientRenderer for SwiftTestClientRenderer {
344 fn language_name(&self) -> &'static str {
345 "swift"
346 }
347
348 fn sanitize_test_name(&self, id: &str) -> String {
349 sanitize_ident(id).to_upper_camel_case()
351 }
352
353 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
359 let _ = writeln!(out, " /// {description}");
360 let _ = writeln!(out, " func test{fn_name}() throws {{");
361 if let Some(reason) = skip_reason {
362 let escaped = escape_swift(reason);
363 let _ = writeln!(out, " try XCTSkipIf(true, \"{escaped}\")");
364 }
365 }
366
367 fn render_test_close(&self, out: &mut String) {
368 let _ = writeln!(out, " }}");
369 }
370
371 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
378 let method = ctx.method.to_uppercase();
379 let fixture_path = escape_swift(ctx.path);
380
381 let _ = writeln!(
382 out,
383 " let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
384 );
385 let _ = writeln!(
386 out,
387 " var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
388 );
389 let _ = writeln!(out, " _req.httpMethod = \"{method}\"");
390
391 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
393 header_pairs.sort_by_key(|(k, _)| k.as_str());
394 for (k, v) in &header_pairs {
395 let expanded_v = expand_fixture_templates(v);
396 let ek = escape_swift(k);
397 let ev = escape_swift(&expanded_v);
398 let _ = writeln!(out, " _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
399 }
400
401 if let Some(body) = ctx.body {
403 let json_str = serde_json::to_string(body).unwrap_or_default();
404 let escaped_body = escape_swift(&json_str);
405 let _ = writeln!(out, " _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
406 let _ = writeln!(
407 out,
408 " _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
409 );
410 }
411
412 let _ = writeln!(out, " var {}: HTTPURLResponse?", ctx.response_var);
413 let _ = writeln!(out, " var _responseData: Data?");
414 let _ = writeln!(out, " let _sema = DispatchSemaphore(value: 0)");
415 let _ = writeln!(
416 out,
417 " URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
418 );
419 let _ = writeln!(out, " {} = resp as? HTTPURLResponse", ctx.response_var);
420 let _ = writeln!(out, " _responseData = data");
421 let _ = writeln!(out, " _sema.signal()");
422 let _ = writeln!(out, " }}.resume()");
423 let _ = writeln!(out, " _sema.wait()");
424 let _ = writeln!(out, " let _resp = try XCTUnwrap({})", ctx.response_var);
425 }
426
427 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
428 let _ = writeln!(out, " XCTAssertEqual(_resp.statusCode, {status})");
429 }
430
431 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
432 let lower_name = name.to_lowercase();
433 let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
434 match expected {
435 "<<present>>" => {
436 let _ = writeln!(out, " XCTAssertNotNil({header_expr})");
437 }
438 "<<absent>>" => {
439 let _ = writeln!(out, " XCTAssertNil({header_expr})");
440 }
441 "<<uuid>>" => {
442 let _ = writeln!(out, " let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
443 let _ = writeln!(
444 out,
445 " XCTAssertNotNil(_hdrVal_{lower_name}.range(of: #\"^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$\"#, options: .regularExpression))"
446 );
447 }
448 exact => {
449 let escaped = escape_swift(exact);
450 let _ = writeln!(out, " XCTAssertEqual({header_expr}, \"{escaped}\")");
451 }
452 }
453 }
454
455 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
456 if let serde_json::Value::String(s) = expected {
457 let escaped = escape_swift(s);
458 let _ = writeln!(
459 out,
460 " let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
461 );
462 let _ = writeln!(
463 out,
464 " XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
465 );
466 } else {
467 let json_str = serde_json::to_string(expected).unwrap_or_default();
468 let escaped = escape_swift(&json_str);
469 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
470 let _ = writeln!(
471 out,
472 " let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
473 );
474 let _ = writeln!(
475 out,
476 " let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
477 );
478 let _ = writeln!(
479 out,
480 " XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
481 );
482 }
483 }
484
485 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
486 if let Some(obj) = expected.as_object() {
487 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
488 let _ = writeln!(
489 out,
490 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
491 );
492 for (key, val) in obj {
493 let escaped_key = escape_swift(key);
494 let swift_val = json_to_swift(val);
495 let _ = writeln!(
496 out,
497 " XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
498 );
499 }
500 }
501 }
502
503 fn render_assert_validation_errors(
504 &self,
505 out: &mut String,
506 _response_var: &str,
507 errors: &[ValidationErrorExpectation],
508 ) {
509 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
510 let _ = writeln!(
511 out,
512 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
513 );
514 let _ = writeln!(
515 out,
516 " let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
517 );
518 for ve in errors {
519 let escaped_msg = escape_swift(&ve.msg);
520 let _ = writeln!(
521 out,
522 " XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
523 );
524 }
525 }
526}
527
528fn render_http_test_method(out: &mut String, fixture: &Fixture) {
533 let Some(http) = &fixture.http else {
534 return;
535 };
536
537 if http.expected_response.status_code == 101 {
539 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
540 let description = fixture.description.replace('"', "\\\"");
541 let _ = writeln!(out, " /// {description}");
542 let _ = writeln!(out, " func test{method_name}() throws {{");
543 let _ = writeln!(
544 out,
545 " try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
546 );
547 let _ = writeln!(out, " }}");
548 return;
549 }
550
551 client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
552}
553
554#[allow(clippy::too_many_arguments)]
559fn render_test_method(
560 out: &mut String,
561 fixture: &Fixture,
562 e2e_config: &E2eConfig,
563 _function_name: &str,
564 _result_var: &str,
565 _args: &[crate::config::ArgMapping],
566 field_resolver: &FieldResolver,
567 result_is_simple: bool,
568 enum_fields: &HashSet<String>,
569 global_client_factory: Option<&str>,
570) {
571 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
573 let lang = "swift";
574 let call_overrides = call_config.overrides.get(lang);
575 let function_name = call_overrides
576 .and_then(|o| o.function.as_ref())
577 .cloned()
578 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
579 let client_factory: Option<&str> = call_overrides
581 .and_then(|o| o.client_factory.as_deref())
582 .or(global_client_factory);
583 let result_var = &call_config.result_var;
584 let args = &call_config.args;
585 let result_is_bytes_any_lang =
592 call_config.result_is_bytes || call_config.overrides.values().any(|o| o.result_is_bytes);
593 eprintln!(
594 "[swift debug] fixture={} call={:?} result_is_bytes={} any_override_bytes={} overrides={}",
595 fixture.id,
596 fixture.call,
597 call_config.result_is_bytes,
598 call_config.overrides.values().any(|o| o.result_is_bytes),
599 call_config.overrides.len()
600 );
601 let result_is_simple = call_config.result_is_simple
602 || call_overrides.is_some_and(|o| o.result_is_simple)
603 || result_is_simple
604 || result_is_bytes_any_lang;
605 let result_is_array = call_config.result_is_array;
606 let result_is_option = call_config.result_is_option || call_overrides.is_some_and(|o| o.result_is_option);
611
612 let method_name = fixture.id.to_upper_camel_case();
613 let description = &fixture.description;
614 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
615 let is_async = call_config.r#async;
616
617 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
619 let collect_snippet_opt = if is_streaming && !expects_error {
620 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(lang, result_var, "chunks")
621 } else {
622 None
623 };
624 if is_streaming && !expects_error && collect_snippet_opt.is_none() {
631 if is_async {
632 let _ = writeln!(out, " func test{method_name}() async throws {{");
633 } else {
634 let _ = writeln!(out, " func test{method_name}() throws {{");
635 }
636 let _ = writeln!(out, " // {description}");
637 let _ = writeln!(
638 out,
639 " try XCTSkipIf(true, \"swift: streaming chunk collection is not yet supported via the swift-bridge surface (fixture: {})\")",
640 fixture.id
641 );
642 let _ = writeln!(out, " }}");
643 return;
644 }
645 let collect_snippet = collect_snippet_opt.unwrap_or_default();
646
647 let has_unresolvable_json_object_arg = {
654 let options_via = call_overrides.and_then(|o| o.options_via.as_deref());
655 options_via.is_none() && args.iter().any(|a| a.arg_type == "json_object" && a.name != "config")
656 };
657
658 if has_unresolvable_json_object_arg {
659 if is_async {
660 let _ = writeln!(out, " func test{method_name}() async throws {{");
661 } else {
662 let _ = writeln!(out, " func test{method_name}() throws {{");
663 }
664 let _ = writeln!(out, " // {description}");
665 let _ = writeln!(
666 out,
667 " try XCTSkipIf(true, \"swift: json_object request construction requires options_via configuration (fixture: {})\");",
668 fixture.id
669 );
670 let _ = writeln!(out, " }}");
671 return;
672 }
673
674 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
678
679 let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
684 let per_call = call_overrides.map(|o| &o.enum_fields);
685 if let Some(pc) = per_call {
686 if !pc.is_empty() {
687 let mut merged = enum_fields.clone();
688 merged.extend(pc.keys().cloned());
689 std::borrow::Cow::Owned(merged)
690 } else {
691 std::borrow::Cow::Borrowed(enum_fields)
692 }
693 } else {
694 std::borrow::Cow::Borrowed(enum_fields)
695 }
696 };
697
698 let options_via_str: Option<&str> = call_overrides.and_then(|o| o.options_via.as_deref());
699 let options_type_str: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
700 let handle_config_fn_owned: Option<String> = call_config
704 .overrides
705 .get("c")
706 .and_then(|c| c.c_engine_factory.as_deref())
707 .map(|ty| format!("{}_from_json", ty.to_snake_case()).to_lower_camel_case());
708 let (setup_lines, args_str) = build_args_and_setup(
709 &fixture.input,
710 args,
711 &fixture.id,
712 fixture.has_host_root_route(),
713 &function_name,
714 options_via_str,
715 options_type_str,
716 handle_config_fn_owned.as_deref(),
717 );
718
719 let args_str = if extra_args.is_empty() {
721 args_str
722 } else if args_str.is_empty() {
723 extra_args.join(", ")
724 } else {
725 format!("{args_str}, {}", extra_args.join(", "))
726 };
727
728 let has_mock = fixture.mock_response.is_some();
733 let (call_setup, call_expr) = if let Some(_factory) = client_factory {
734 let env_key = format!("MOCK_SERVER_{}", fixture.id.to_ascii_uppercase().replace('-', "_"));
735 let mock_url = if fixture.has_host_root_route() {
736 format!(
737 "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\")",
738 fixture.id
739 )
740 } else {
741 format!(
742 "ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\"",
743 fixture.id
744 )
745 };
746 let client_constructor = if has_mock {
747 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
748 } else {
749 if let Some(env_var) = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()) {
751 format!(
752 "let _apiKey = ProcessInfo.processInfo.environment[\"{env_var}\"]\n \
753 let _baseUrl: String? = _apiKey != nil ? nil : {mock_url}\n \
754 let _client = try DefaultClient(apiKey: _apiKey ?? \"test-key\", baseUrl: _baseUrl)"
755 )
756 } else {
757 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
758 }
759 };
760 let expr = if is_async {
761 format!("try await _client.{function_name}({args_str})")
762 } else {
763 format!("try _client.{function_name}({args_str})")
764 };
765 (Some(client_constructor), expr)
766 } else {
767 let expr = if is_async {
769 format!("try await {function_name}({args_str})")
770 } else {
771 format!("try {function_name}({args_str})")
772 };
773 (None, expr)
774 };
775 let _ = function_name;
777
778 if is_async {
779 let _ = writeln!(out, " func test{method_name}() async throws {{");
780 } else {
781 let _ = writeln!(out, " func test{method_name}() throws {{");
782 }
783 let _ = writeln!(out, " // {description}");
784
785 if expects_error {
786 if is_async {
790 let _ = writeln!(out, " do {{");
795 for line in &setup_lines {
796 let _ = writeln!(out, " {line}");
797 }
798 if let Some(setup) = &call_setup {
799 let _ = writeln!(out, " {setup}");
800 }
801 let _ = writeln!(out, " _ = {call_expr}");
802 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
803 let _ = writeln!(out, " }} catch {{");
804 let _ = writeln!(out, " // success");
805 let _ = writeln!(out, " }}");
806 } else {
807 let _ = writeln!(out, " do {{");
814 for line in &setup_lines {
815 let _ = writeln!(out, " {line}");
816 }
817 if let Some(setup) = &call_setup {
818 let _ = writeln!(out, " {setup}");
819 }
820 let _ = writeln!(out, " _ = {call_expr}");
821 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
822 let _ = writeln!(out, " }} catch {{");
823 let _ = writeln!(out, " // success");
824 let _ = writeln!(out, " }}");
825 }
826 let _ = writeln!(out, " }}");
827 return;
828 }
829
830 for line in &setup_lines {
831 let _ = writeln!(out, " {line}");
832 }
833
834 if let Some(setup) = &call_setup {
836 let _ = writeln!(out, " {setup}");
837 }
838
839 let _ = writeln!(out, " let {result_var} = {call_expr}");
840
841 if !collect_snippet.is_empty() {
844 for line in collect_snippet.lines() {
845 let _ = writeln!(out, " {line}");
846 }
847 }
848
849 for assertion in &fixture.assertions {
850 render_assertion(
851 out,
852 assertion,
853 result_var,
854 field_resolver,
855 result_is_simple,
856 result_is_array,
857 result_is_option,
858 &effective_enum_fields,
859 );
860 }
861
862 let _ = writeln!(out, " }}");
863}
864
865#[allow(clippy::too_many_arguments)]
866fn build_args_and_setup(
880 input: &serde_json::Value,
881 args: &[crate::config::ArgMapping],
882 fixture_id: &str,
883 has_host_root_route: bool,
884 function_name: &str,
885 options_via: Option<&str>,
886 options_type: Option<&str>,
887 handle_config_fn: Option<&str>,
888) -> (Vec<String>, String) {
889 if args.is_empty() {
890 return (Vec::new(), String::new());
891 }
892
893 let mut setup_lines: Vec<String> = Vec::new();
894 let mut parts: Vec<String> = Vec::new();
895
896 let later_emits: Vec<bool> = (0..args.len())
901 .map(|i| {
902 args.iter().skip(i + 1).any(|a| {
903 let f = a.field.strip_prefix("input.").unwrap_or(&a.field);
904 let v = input.get(f);
905 let has_value = matches!(v, Some(x) if !x.is_null());
906 has_value || !a.optional || (a.arg_type == "json_object" && a.name == "config")
907 })
908 })
909 .collect();
910
911 for (idx, arg) in args.iter().enumerate() {
912 if arg.arg_type == "mock_url" {
913 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_ascii_uppercase().replace('-', "_"));
914 let url_expr = if has_host_root_route {
915 format!(
916 "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\")"
917 )
918 } else {
919 format!("ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"")
920 };
921 setup_lines.push(format!("let {} = {url_expr}", arg.name));
922 parts.push(arg.name.clone());
923 continue;
924 }
925
926 if arg.arg_type == "handle" {
927 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
928 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
929 let config_val = input.get(field);
930 let has_config = config_val
931 .is_some_and(|v| !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty())));
932 if has_config {
933 if let Some(from_json_fn) = handle_config_fn {
934 let json_str = serde_json::to_string(config_val.unwrap()).unwrap_or_default();
935 let escaped = escape_swift_str(&json_str);
936 let config_var = format!("{}Config", arg.name.to_lower_camel_case());
937 setup_lines.push(format!("let {config_var} = try {from_json_fn}(\"{escaped}\")"));
938 setup_lines.push(format!("let {var_name} = try createEngine({config_var})"));
939 } else {
940 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
941 }
942 } else {
943 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
944 }
945 parts.push(var_name);
946 continue;
947 }
948
949 if arg.arg_type == "bytes" {
954 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
955 let val = input.get(field);
956 match val {
957 None | Some(serde_json::Value::Null) if arg.optional => {
958 if later_emits[idx] {
959 parts.push("nil".to_string());
960 }
961 }
962 None | Some(serde_json::Value::Null) => {
963 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
964 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
965 parts.push(var_name);
966 }
967 Some(serde_json::Value::String(s)) => {
968 let escaped = escape_swift(s);
969 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
970 let data_var = format!("{}Data", arg.name.to_lower_camel_case());
971 setup_lines.push(format!(
972 "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
973 ));
974 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
975 setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
976 parts.push(var_name);
977 }
978 Some(serde_json::Value::Array(arr)) => {
979 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
980 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
981 for v in arr {
982 if let Some(n) = v.as_u64() {
983 setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
984 }
985 }
986 parts.push(var_name);
987 }
988 Some(other) => {
989 let json_str = serde_json::to_string(other).unwrap_or_default();
991 let escaped = escape_swift(&json_str);
992 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
993 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
994 setup_lines.push(format!(
995 "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
996 ));
997 parts.push(var_name);
998 }
999 }
1000 continue;
1001 }
1002
1003 let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
1008 let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
1009 if is_config_arg && !is_batch_fn {
1010 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1011 let val = input.get(field);
1012 let json_str = match val {
1013 None | Some(serde_json::Value::Null) => "{}".to_string(),
1014 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1015 };
1016 let escaped = escape_swift(&json_str);
1017 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
1018 setup_lines.push(format!("let {var_name} = try extractionConfigFromJson(\"{escaped}\")"));
1019 parts.push(var_name);
1020 continue;
1021 }
1022
1023 if arg.arg_type == "json_object" && options_via == Some("from_json") {
1028 if let Some(type_name) = options_type {
1029 let resolved_val = super::resolve_field(input, &arg.field);
1030 let json_str = match resolved_val {
1031 serde_json::Value::Null => "{}".to_string(),
1032 v => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1033 };
1034 let escaped = escape_swift(&json_str);
1035 let var_name = format!("_{}", arg.name.to_lower_camel_case());
1036 let from_json_fn = format!("{}FromJson", type_name.to_lower_camel_case());
1037 setup_lines.push(format!("let {var_name} = try {from_json_fn}(\"{escaped}\")"));
1038 parts.push(var_name);
1039 continue;
1040 }
1041 }
1042
1043 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1044 let val = input.get(field);
1045 match val {
1046 None | Some(serde_json::Value::Null) if arg.optional => {
1047 if later_emits[idx] {
1051 parts.push("nil".to_string());
1052 }
1053 }
1054 None | Some(serde_json::Value::Null) => {
1055 let default_val = match arg.arg_type.as_str() {
1056 "string" => "\"\"".to_string(),
1057 "int" | "integer" => "0".to_string(),
1058 "float" | "number" => "0.0".to_string(),
1059 "bool" | "boolean" => "false".to_string(),
1060 _ => "nil".to_string(),
1061 };
1062 parts.push(default_val);
1063 }
1064 Some(v) => {
1065 parts.push(json_to_swift(v));
1066 }
1067 }
1068 }
1069
1070 (setup_lines, parts.join(", "))
1071}
1072
1073#[allow(clippy::too_many_arguments)]
1074fn render_assertion(
1075 out: &mut String,
1076 assertion: &Assertion,
1077 result_var: &str,
1078 field_resolver: &FieldResolver,
1079 result_is_simple: bool,
1080 result_is_array: bool,
1081 result_is_option: bool,
1082 enum_fields: &HashSet<String>,
1083) {
1084 let bare_result_is_option = result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1089 if let Some(f) = &assertion.field {
1092 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1093 if let Some(expr) =
1094 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "swift", "chunks")
1095 {
1096 let line = match assertion.assertion_type.as_str() {
1097 "count_min" => {
1098 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1099 format!(" XCTAssertGreaterThanOrEqual(chunks.count, {n})\n")
1100 } else {
1101 String::new()
1102 }
1103 }
1104 "count_equals" => {
1105 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1106 format!(" XCTAssertEqual(chunks.count, {n})\n")
1107 } else {
1108 String::new()
1109 }
1110 }
1111 "equals" => {
1112 if let Some(serde_json::Value::String(s)) = &assertion.value {
1113 let escaped = escape_swift(s);
1114 format!(" XCTAssertEqual({expr}, \"{escaped}\")\n")
1115 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1116 format!(" XCTAssertEqual({expr}, {b})\n")
1117 } else {
1118 String::new()
1119 }
1120 }
1121 "not_empty" => {
1122 format!(" XCTAssertFalse({expr}.isEmpty, \"expected non-empty\")\n")
1123 }
1124 "is_empty" => {
1125 format!(" XCTAssertTrue({expr}.isEmpty, \"expected empty\")\n")
1126 }
1127 "is_true" => {
1128 format!(" XCTAssertTrue({expr})\n")
1129 }
1130 "is_false" => {
1131 format!(" XCTAssertFalse({expr})\n")
1132 }
1133 "greater_than" => {
1134 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1135 format!(" XCTAssertGreaterThan(chunks.count, {n})\n")
1136 } else {
1137 String::new()
1138 }
1139 }
1140 "contains" => {
1141 if let Some(serde_json::Value::String(s)) = &assertion.value {
1142 let escaped = escape_swift(s);
1143 format!(
1144 " XCTAssertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1145 )
1146 } else {
1147 String::new()
1148 }
1149 }
1150 _ => format!(
1151 " // streaming field '{f}': assertion type '{}' not rendered\n",
1152 assertion.assertion_type
1153 ),
1154 };
1155 if !line.is_empty() {
1156 out.push_str(&line);
1157 }
1158 }
1159 return;
1160 }
1161 }
1162
1163 if let Some(f) = &assertion.field {
1165 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1166 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1167 return;
1168 }
1169 }
1170
1171 if let Some(f) = &assertion.field {
1176 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1177 let _ = writeln!(
1178 out,
1179 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
1180 );
1181 return;
1182 }
1183 }
1184
1185 let field_is_enum = assertion
1187 .field
1188 .as_deref()
1189 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1190
1191 let field_is_optional = assertion.field.as_deref().is_some_and(|f| {
1192 !f.is_empty() && (field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f)))
1193 });
1194 let field_is_array = assertion.field.as_deref().is_some_and(|f| {
1195 !f.is_empty()
1196 && (field_resolver.is_array(f)
1197 || field_resolver.is_array(field_resolver.resolve(f))
1198 || field_resolver.is_collection_root(f)
1199 || field_resolver.is_collection_root(field_resolver.resolve(f)))
1200 });
1201
1202 let field_expr_raw = if result_is_simple {
1203 result_var.to_string()
1204 } else {
1205 match &assertion.field {
1206 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
1207 _ => result_var.to_string(),
1208 }
1209 };
1210
1211 let local_suffix = {
1221 use std::hash::{Hash, Hasher};
1222 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1223 assertion.field.hash(&mut hasher);
1224 assertion
1225 .value
1226 .as_ref()
1227 .map(|v| v.to_string())
1228 .unwrap_or_default()
1229 .hash(&mut hasher);
1230 format!(
1231 "{}_{:x}",
1232 assertion.assertion_type.replace(['-', '.'], "_"),
1233 hasher.finish() & 0xffff_ffff,
1234 )
1235 };
1236 let (vec_setup, field_expr) = materialise_vec_temporaries(&field_expr_raw, &local_suffix);
1237 let field_uses_traversal = assertion.field.as_deref().is_some_and(|f| f.contains("[]."));
1242 let traversal_skips_field_expr = field_uses_traversal
1243 && matches!(
1244 assertion.assertion_type.as_str(),
1245 "contains" | "not_contains" | "not_empty" | "is_empty"
1246 );
1247 if !traversal_skips_field_expr {
1248 for line in &vec_setup {
1249 let _ = writeln!(out, " {line}");
1250 }
1251 }
1252
1253 let accessor_is_optional = field_expr.contains("?.");
1259
1260 let string_expr = if field_is_enum && (field_is_optional || accessor_is_optional) {
1269 format!("({field_expr}?.toString() ?? \"\")")
1272 } else if field_is_enum {
1273 format!("{field_expr}.toString()")
1278 } else if field_is_optional {
1279 format!("({field_expr}?.toString() ?? \"\")")
1281 } else if accessor_is_optional {
1282 format!("({field_expr}.toString() ?? \"\")")
1285 } else {
1286 format!("{field_expr}.toString()")
1287 };
1288
1289 match assertion.assertion_type.as_str() {
1290 "equals" => {
1291 if let Some(expected) = &assertion.value {
1292 let swift_val = json_to_swift(expected);
1293 if expected.is_string() {
1294 if field_is_enum {
1295 let trim_expr = format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespaces)");
1299 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1300 } else {
1301 let trim_expr = format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespaces)");
1306 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1307 }
1308 } else {
1309 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
1310 }
1311 }
1312 }
1313 "contains" => {
1314 if let Some(expected) = &assertion.value {
1315 let swift_val = json_to_swift(expected);
1316 let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
1319 if result_is_simple && result_is_array && no_field {
1320 let _ = writeln!(
1323 out,
1324 " XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1325 );
1326 } else {
1327 let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1329 if let Some(dot) = f.find("[].") {
1330 let array_part = &f[..dot];
1331 let elem_part = &f[dot + 3..];
1332 let line = swift_traversal_contains_assert(
1333 array_part,
1334 elem_part,
1335 f,
1336 &swift_val,
1337 result_var,
1338 false,
1339 &format!("expected to contain: \\({swift_val})"),
1340 enum_fields,
1341 field_resolver,
1342 );
1343 let _ = writeln!(out, "{line}");
1344 true
1345 } else {
1346 false
1347 }
1348 } else {
1349 false
1350 };
1351 if !traversal_handled {
1352 let field_is_array = assertion
1354 .field
1355 .as_deref()
1356 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1357 if field_is_array {
1358 let contains_expr =
1359 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1360 let _ = writeln!(
1361 out,
1362 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1363 );
1364 } else if field_is_enum {
1365 let _ = writeln!(
1368 out,
1369 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1370 );
1371 } else {
1372 let _ = writeln!(
1373 out,
1374 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1375 );
1376 }
1377 }
1378 }
1379 }
1380 }
1381 "contains_all" => {
1382 if let Some(values) = &assertion.values {
1383 if let Some(f) = assertion.field.as_deref() {
1385 if let Some(dot) = f.find("[].") {
1386 let array_part = &f[..dot];
1387 let elem_part = &f[dot + 3..];
1388 for val in values {
1389 let swift_val = json_to_swift(val);
1390 let line = swift_traversal_contains_assert(
1391 array_part,
1392 elem_part,
1393 f,
1394 &swift_val,
1395 result_var,
1396 false,
1397 &format!("expected to contain: \\({swift_val})"),
1398 enum_fields,
1399 field_resolver,
1400 );
1401 let _ = writeln!(out, "{line}");
1402 }
1403 } else {
1405 let field_is_array = field_resolver.is_array(field_resolver.resolve(f));
1407 if field_is_array {
1408 let contains_expr =
1409 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1410 for val in values {
1411 let swift_val = json_to_swift(val);
1412 let _ = writeln!(
1413 out,
1414 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1415 );
1416 }
1417 } else if field_is_enum {
1418 for val in values {
1421 let swift_val = json_to_swift(val);
1422 let _ = writeln!(
1423 out,
1424 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1425 );
1426 }
1427 } else {
1428 for val in values {
1429 let swift_val = json_to_swift(val);
1430 let _ = writeln!(
1431 out,
1432 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1433 );
1434 }
1435 }
1436 }
1437 } else {
1438 for val in values {
1440 let swift_val = json_to_swift(val);
1441 let _ = writeln!(
1442 out,
1443 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1444 );
1445 }
1446 }
1447 }
1448 }
1449 "not_contains" => {
1450 if let Some(expected) = &assertion.value {
1451 let swift_val = json_to_swift(expected);
1452 let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1454 if let Some(dot) = f.find("[].") {
1455 let array_part = &f[..dot];
1456 let elem_part = &f[dot + 3..];
1457 let line = swift_traversal_contains_assert(
1458 array_part,
1459 elem_part,
1460 f,
1461 &swift_val,
1462 result_var,
1463 true,
1464 &format!("expected NOT to contain: \\({swift_val})"),
1465 enum_fields,
1466 field_resolver,
1467 );
1468 let _ = writeln!(out, "{line}");
1469 true
1470 } else {
1471 false
1472 }
1473 } else {
1474 false
1475 };
1476 if !traversal_handled {
1477 let _ = writeln!(
1478 out,
1479 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
1480 );
1481 }
1482 }
1483 }
1484 "not_empty" => {
1485 let traversal_not_empty_handled = if let Some(f) = assertion.field.as_deref() {
1492 if let Some(dot) = f.find("[].") {
1493 let array_part = &f[..dot];
1494 let elem_part = &f[dot + 3..];
1495 let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1496 let resolved_full = field_resolver.resolve(f);
1497 let resolved_elem_part = resolved_full
1498 .find("[].")
1499 .map(|d| &resolved_full[d + 3..])
1500 .unwrap_or(elem_part);
1501 let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1502 let elem_is_enum = enum_fields.contains(f) || enum_fields.contains(resolved_full);
1503 let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1504 || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1505 let elem_str = if elem_is_enum {
1506 format!("{elem_accessor}.to_string().toString()")
1507 } else if elem_is_optional {
1508 format!("({elem_accessor}?.toString() ?? \"\")")
1509 } else {
1510 format!("{elem_accessor}.toString()")
1511 };
1512 let _ = writeln!(
1513 out,
1514 " XCTAssertTrue({array_accessor}.contains(where: {{ !{elem_str}.isEmpty }}), \"expected non-empty value\")"
1515 );
1516 true
1517 } else {
1518 false
1519 }
1520 } else {
1521 false
1522 };
1523 if !traversal_not_empty_handled {
1524 if bare_result_is_option {
1525 let _ = writeln!(out, " XCTAssertNotNil({result_var}, \"expected non-nil value\")");
1526 } else if field_is_optional {
1527 let _ = writeln!(out, " XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
1528 } else if field_is_array {
1529 let _ = writeln!(
1530 out,
1531 " XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
1532 );
1533 } else if result_is_simple {
1534 let _ = writeln!(
1536 out,
1537 " XCTAssertFalse({result_var}.isEmpty, \"expected non-empty value\")"
1538 );
1539 } else {
1540 let _ = writeln!(
1545 out,
1546 " XCTAssertGreaterThan({field_expr}.len(), 0, \"expected non-empty value\")"
1547 );
1548 }
1549 }
1550 }
1551 "is_empty" => {
1552 if bare_result_is_option {
1553 let _ = writeln!(out, " XCTAssertNil({result_var}, \"expected nil value\")");
1554 } else if field_is_optional {
1555 let _ = writeln!(out, " XCTAssertNil({field_expr}, \"expected nil value\")");
1556 } else if field_is_array {
1557 let _ = writeln!(
1558 out,
1559 " XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
1560 );
1561 } else {
1562 let _ = writeln!(
1565 out,
1566 " XCTAssertEqual({field_expr}.len(), 0, \"expected empty value\")"
1567 );
1568 }
1569 }
1570 "contains_any" => {
1571 if let Some(values) = &assertion.values {
1572 let checks: Vec<String> = values
1573 .iter()
1574 .map(|v| {
1575 let swift_val = json_to_swift(v);
1576 format!("{string_expr}.contains({swift_val})")
1577 })
1578 .collect();
1579 let joined = checks.join(" || ");
1580 let _ = writeln!(
1581 out,
1582 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
1583 );
1584 }
1585 }
1586 "greater_than" => {
1587 if let Some(val) = &assertion.value {
1588 let swift_val = json_to_swift(val);
1589 let field_is_optional = accessor_is_optional
1592 || assertion.field.as_deref().is_some_and(|f| {
1593 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1594 });
1595 let compare_expr = if field_is_optional {
1596 format!("({field_expr} ?? 0)")
1597 } else {
1598 field_expr.clone()
1599 };
1600 let _ = writeln!(out, " XCTAssertGreaterThan({compare_expr}, {swift_val})");
1601 }
1602 }
1603 "less_than" => {
1604 if let Some(val) = &assertion.value {
1605 let swift_val = json_to_swift(val);
1606 let field_is_optional = accessor_is_optional
1607 || assertion.field.as_deref().is_some_and(|f| {
1608 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1609 });
1610 let compare_expr = if field_is_optional {
1611 format!("({field_expr} ?? 0)")
1612 } else {
1613 field_expr.clone()
1614 };
1615 let _ = writeln!(out, " XCTAssertLessThan({compare_expr}, {swift_val})");
1616 }
1617 }
1618 "greater_than_or_equal" => {
1619 if let Some(val) = &assertion.value {
1620 let swift_val = json_to_swift(val);
1621 let field_is_optional = accessor_is_optional
1624 || assertion.field.as_deref().is_some_and(|f| {
1625 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1626 });
1627 let compare_expr = if field_is_optional {
1628 format!("({field_expr} ?? 0)")
1629 } else {
1630 field_expr.clone()
1631 };
1632 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
1633 }
1634 }
1635 "less_than_or_equal" => {
1636 if let Some(val) = &assertion.value {
1637 let swift_val = json_to_swift(val);
1638 let field_is_optional = accessor_is_optional
1639 || assertion.field.as_deref().is_some_and(|f| {
1640 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1641 });
1642 let compare_expr = if field_is_optional {
1643 format!("({field_expr} ?? 0)")
1644 } else {
1645 field_expr.clone()
1646 };
1647 let _ = writeln!(out, " XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
1648 }
1649 }
1650 "starts_with" => {
1651 if let Some(expected) = &assertion.value {
1652 let swift_val = json_to_swift(expected);
1653 let _ = writeln!(
1654 out,
1655 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1656 );
1657 }
1658 }
1659 "ends_with" => {
1660 if let Some(expected) = &assertion.value {
1661 let swift_val = json_to_swift(expected);
1662 let _ = writeln!(
1663 out,
1664 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1665 );
1666 }
1667 }
1668 "min_length" => {
1669 if let Some(val) = &assertion.value {
1670 if let Some(n) = val.as_u64() {
1671 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1674 }
1675 }
1676 }
1677 "max_length" => {
1678 if let Some(val) = &assertion.value {
1679 if let Some(n) = val.as_u64() {
1680 let _ = writeln!(out, " XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1681 }
1682 }
1683 }
1684 "count_min" => {
1685 if let Some(val) = &assertion.value {
1686 if let Some(n) = val.as_u64() {
1687 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1691 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1692 }
1693 }
1694 }
1695 "count_equals" => {
1696 if let Some(val) = &assertion.value {
1697 if let Some(n) = val.as_u64() {
1698 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1699 let _ = writeln!(out, " XCTAssertEqual({count_expr}, {n})");
1700 }
1701 }
1702 }
1703 "is_true" => {
1704 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
1705 }
1706 "is_false" => {
1707 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
1708 }
1709 "matches_regex" => {
1710 if let Some(expected) = &assertion.value {
1711 let swift_val = json_to_swift(expected);
1712 let _ = writeln!(
1713 out,
1714 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1715 );
1716 }
1717 }
1718 "not_error" => {
1719 }
1721 "error" => {
1722 }
1724 "method_result" => {
1725 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
1726 }
1727 other => {
1728 panic!("Swift e2e generator: unsupported assertion type: {other}");
1729 }
1730 }
1731}
1732
1733fn materialise_vec_temporaries(expr: &str, name_suffix: &str) -> (Vec<String>, String) {
1754 let Some(idx) = expr.find("()[") else {
1755 return (Vec::new(), expr.to_string());
1756 };
1757 let after_open = idx + 3; let Some(close_rel) = expr[after_open..].find(']') else {
1759 return (Vec::new(), expr.to_string());
1760 };
1761 let subscript_end = after_open + close_rel; let prefix = &expr[..idx + 2]; let subscript = &expr[idx + 2..=subscript_end]; let tail = &expr[subscript_end + 1..]; let method_dot = expr[..idx].rfind('.').unwrap_or(0);
1766 let method = &expr[method_dot + 1..idx];
1767 let local = format!("_vec_{}_{}", method, name_suffix);
1768 let setup = format!("let {local} = {prefix}");
1769 let rewritten = format!("{local}{subscript}{tail}");
1770 (vec![setup], rewritten)
1771}
1772
1773fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1776 let resolved = field_resolver.resolve(field);
1777 let parts: Vec<&str> = resolved.split('.').collect();
1778
1779 let mut out = result_var.to_string();
1782 let mut has_optional = false;
1783 let mut path_so_far = String::new();
1784 let total = parts.len();
1785 for (i, part) in parts.iter().enumerate() {
1786 let is_leaf = i == total - 1;
1787 let (field_name, subscript): (&str, Option<&str>) = if let Some(bracket_pos) = part.find('[') {
1791 (&part[..bracket_pos], Some(&part[bracket_pos..]))
1792 } else {
1793 (part, None)
1794 };
1795
1796 if !path_so_far.is_empty() {
1797 path_so_far.push('.');
1798 }
1799 let base_path = {
1803 let mut p = path_so_far.clone();
1804 p.push_str(field_name);
1805 p
1806 };
1807 path_so_far.push_str(part);
1810
1811 out.push('.');
1812 out.push_str(field_name);
1813 if let Some(sub) = subscript {
1814 let field_is_optional = field_resolver.is_optional(&base_path);
1818 if field_is_optional {
1819 out.push_str("()?");
1820 has_optional = true;
1821 } else {
1822 out.push_str("()");
1823 }
1824 out.push_str(sub);
1825 } else {
1835 out.push_str("()");
1836 if !is_leaf && field_resolver.is_optional(&base_path) {
1839 out.push('?');
1840 has_optional = true;
1841 }
1842 }
1843 }
1844 (out, has_optional)
1845}
1846
1847#[allow(clippy::too_many_arguments)]
1869fn swift_traversal_contains_assert(
1870 array_part: &str,
1871 element_part: &str,
1872 full_field: &str,
1873 val_expr: &str,
1874 result_var: &str,
1875 negate: bool,
1876 msg: &str,
1877 enum_fields: &std::collections::HashSet<String>,
1878 field_resolver: &FieldResolver,
1879) -> String {
1880 let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1881 let resolved_full = field_resolver.resolve(full_field);
1882 let resolved_elem_part = resolved_full
1883 .find("[].")
1884 .map(|d| &resolved_full[d + 3..])
1885 .unwrap_or(element_part);
1886 let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1887 let elem_is_enum = enum_fields.contains(full_field) || enum_fields.contains(resolved_full);
1888 let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1889 || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1890 let elem_str = if elem_is_enum {
1891 format!("{elem_accessor}.toString()")
1894 } else if elem_is_optional {
1895 format!("({elem_accessor}?.toString() ?? \"\")")
1896 } else {
1897 format!("{elem_accessor}.toString()")
1898 };
1899 let assert_fn = if negate { "XCTAssertFalse" } else { "XCTAssertTrue" };
1900 format!(" {assert_fn}({array_accessor}.contains(where: {{ {elem_str}.contains({val_expr}) }}), \"{msg}\")")
1901}
1902
1903fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1904 let Some(f) = field else {
1905 return format!("{result_var}.map {{ $0.as_str().toString() }}");
1906 };
1907 let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
1908 format!("{accessor}?.map {{ $0.as_str().toString() }}")
1911}
1912
1913fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1922 let Some(f) = field else {
1923 return format!("{result_var}.count");
1924 };
1925 let (accessor, mut has_optional) = swift_build_accessor(f, result_var, field_resolver);
1926 if field_resolver.is_optional(f) {
1928 has_optional = true;
1929 }
1930 if has_optional {
1931 if accessor.contains("?.") {
1934 format!("{accessor}.count ?? 0")
1935 } else {
1936 format!("({accessor}?.count ?? 0)")
1939 }
1940 } else {
1941 format!("{accessor}.count")
1942 }
1943}
1944
1945fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
1951 let mut components = std::path::PathBuf::new();
1952 for component in path.components() {
1953 match component {
1954 std::path::Component::ParentDir => {
1955 if !components.as_os_str().is_empty() {
1958 components.pop();
1959 } else {
1960 components.push(component);
1961 }
1962 }
1963 std::path::Component::CurDir => {}
1964 other => components.push(other),
1965 }
1966 }
1967 components
1968}
1969
1970fn json_to_swift(value: &serde_json::Value) -> String {
1972 match value {
1973 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
1974 serde_json::Value::Bool(b) => b.to_string(),
1975 serde_json::Value::Number(n) => n.to_string(),
1976 serde_json::Value::Null => "nil".to_string(),
1977 serde_json::Value::Array(arr) => {
1978 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
1979 format!("[{}]", items.join(", "))
1980 }
1981 serde_json::Value::Object(_) => {
1982 let json_str = serde_json::to_string(value).unwrap_or_default();
1983 format!("\"{}\"", escape_swift(&json_str))
1984 }
1985 }
1986}
1987
1988fn escape_swift(s: &str) -> String {
1990 escape_swift_str(s)
1991}
1992
1993#[cfg(test)]
1994mod tests {
1995 use super::*;
1996 use crate::field_access::FieldResolver;
1997 use std::collections::{HashMap, HashSet};
1998
1999 fn make_resolver_tool_calls() -> FieldResolver {
2000 let mut optional = HashSet::new();
2004 optional.insert("choices.message.tool_calls".to_string());
2005 let mut arrays = HashSet::new();
2006 arrays.insert("choices".to_string());
2007 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
2008 }
2009
2010 #[test]
2017 fn optional_vec_subscript_does_not_emit_trailing_question_mark_before_next_segment() {
2018 let resolver = make_resolver_tool_calls();
2019 let (accessor, has_optional) =
2022 swift_build_accessor("choices[0].message.tool_calls[0].function.name", "result", &resolver);
2023 assert!(
2026 accessor.contains("tool_calls()?[0]"),
2027 "expected `tool_calls()?[0]` for optional tool_calls, got: {accessor}"
2028 );
2029 assert!(
2031 !accessor.contains("?[0]?"),
2032 "must not emit trailing `?` after subscript index: {accessor}"
2033 );
2034 assert!(has_optional, "expected has_optional=true for optional field chain");
2036 assert!(
2038 accessor.contains("[0].function()"),
2039 "expected `.function()` (non-optional) after subscript: {accessor}"
2040 );
2041 }
2042}