1use crate::config::E2eConfig;
16use crate::escape::{escape_java as escape_swift_str, expand_fixture_templates, sanitize_filename, sanitize_ident};
17use crate::field_access::{FieldResolver, SwiftFirstClassMap};
18use crate::fixture::{Assertion, Fixture, FixtureGroup, ValidationErrorExpectation};
19use alef_core::backend::GeneratedFile;
20use alef_core::config::ResolvedCrateConfig;
21use alef_core::hash::{self, CommentStyle};
22use alef_core::template_versions::toolchain;
23use anyhow::Result;
24use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
25use std::collections::HashMap;
26use std::collections::HashSet;
27use std::fmt::Write as FmtWrite;
28use std::path::PathBuf;
29
30use super::E2eCodegen;
31use super::client;
32
33pub struct SwiftE2eCodegen;
35
36impl E2eCodegen for SwiftE2eCodegen {
37 fn generate(
38 &self,
39 groups: &[FixtureGroup],
40 e2e_config: &E2eConfig,
41 config: &ResolvedCrateConfig,
42 type_defs: &[alef_core::ir::TypeDef],
43 _enums: &[alef_core::ir::EnumDef],
44 ) -> Result<Vec<GeneratedFile>> {
45 let lang = self.language_name();
46 let output_base = PathBuf::from(e2e_config.effective_output()).join("swift_e2e");
54
55 let mut files = Vec::new();
56
57 let call = &e2e_config.call;
59 let overrides = call.overrides.get(lang);
60 let function_name = overrides
61 .and_then(|o| o.function.as_ref())
62 .cloned()
63 .unwrap_or_else(|| call.function.clone());
64 let result_var = &call.result_var;
65 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
66
67 let swift_pkg = e2e_config.resolve_package("swift");
69 let pkg_name = swift_pkg
70 .as_ref()
71 .and_then(|p| p.name.as_ref())
72 .cloned()
73 .unwrap_or_else(|| config.name.to_upper_camel_case());
74 let pkg_path = swift_pkg
75 .as_ref()
76 .and_then(|p| p.path.as_ref())
77 .cloned()
78 .unwrap_or_else(|| "../../packages/swift".to_string());
79 let pkg_version = swift_pkg
80 .as_ref()
81 .and_then(|p| p.version.as_ref())
82 .cloned()
83 .or_else(|| config.resolved_version())
84 .unwrap_or_else(|| "0.1.0".to_string());
85
86 let module_name = pkg_name.as_str();
88
89 let registry_url = config
93 .try_github_repo()
94 .map(|repo| {
95 let base = repo.trim_end_matches('/').trim_end_matches(".git");
96 format!("{base}.git")
97 })
98 .unwrap_or_else(|_| format!("https://example.invalid/{module_name}.git"));
99
100 files.push(GeneratedFile {
103 path: output_base.join("Package.swift"),
104 content: render_package_swift(module_name, ®istry_url, &pkg_path, &pkg_version, e2e_config.dep_mode),
105 generated_header: false,
106 });
107
108 let tests_base = output_base.clone();
110
111 let swift_first_class_map = build_swift_first_class_map(type_defs, e2e_config);
117
118 let swift_first_class_map_ref = swift_first_class_map;
119
120 let client_factory: Option<&str> = overrides.and_then(|o| o.client_factory.as_deref());
122
123 files.push(GeneratedFile {
134 path: tests_base
135 .join("Tests")
136 .join(format!("{module_name}E2ETests"))
137 .join("TestHelpers.swift"),
138 content: render_test_helpers_swift(),
139 generated_header: true,
140 });
141
142 for group in groups {
144 let active: Vec<&Fixture> = group
145 .fixtures
146 .iter()
147 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
148 .collect();
149
150 if active.is_empty() {
151 continue;
152 }
153
154 let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
155 let filename = format!("{class_name}.swift");
156 let content = render_test_file(
157 &group.category,
158 &active,
159 e2e_config,
160 module_name,
161 &class_name,
162 &function_name,
163 result_var,
164 &e2e_config.call.args,
165 result_is_simple,
166 client_factory,
167 &swift_first_class_map_ref,
168 );
169 files.push(GeneratedFile {
170 path: tests_base
171 .join("Tests")
172 .join(format!("{module_name}E2ETests"))
173 .join(filename),
174 content,
175 generated_header: true,
176 });
177 }
178
179 Ok(files)
180 }
181
182 fn language_name(&self) -> &'static str {
183 "swift"
184 }
185}
186
187const SWIFT_FORMAT_IGNORE_DIRECTIVE: &str = "// swift-format-ignore-file\n\n";
203
204fn render_test_helpers_swift() -> String {
209 let header = hash::header(CommentStyle::DoubleSlash);
210 let ignore = SWIFT_FORMAT_IGNORE_DIRECTIVE;
211 format!(
212 r#"{header}{ignore}import Foundation
213import RustBridge
214
215// Make `RustString` print its content in XCTest failure output. Without this,
216// every error thrown from the swift-bridge layer surfaces as
217// `caught error: "RustBridge.RustString"` with the actual message hidden
218// inside the opaque class instance. The `@retroactive` keyword acknowledges
219// that the conformed-to protocol (`CustomStringConvertible`) and the
220// conforming type (`RustString`) both live outside this module — required by
221// Swift 6 to silence the retroactive-conformance warning. swift-bridge does
222// not give `RustString` a `description` of its own, so there is no conflict.
223extension RustString: @retroactive CustomStringConvertible {{
224 public var description: String {{ self.toString() }}
225}}
226"#
227 )
228}
229
230fn render_package_swift(
231 module_name: &str,
232 registry_url: &str,
233 pkg_path: &str,
234 pkg_version: &str,
235 dep_mode: crate::config::DependencyMode,
236) -> String {
237 let min_macos = toolchain::SWIFT_MIN_MACOS;
238
239 let (dep_block, product_dep) = match dep_mode {
243 crate::config::DependencyMode::Registry => {
244 let dep = format!(r#" .package(url: "{registry_url}", from: "{pkg_version}")"#);
245 let pkg_id = registry_url
246 .trim_end_matches('/')
247 .trim_end_matches(".git")
248 .split('/')
249 .next_back()
250 .unwrap_or(module_name);
251 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
252 (dep, prod)
253 }
254 crate::config::DependencyMode::Local => {
255 let pkg_id = pkg_path.trim_end_matches('/').rsplit('/').next().unwrap_or(module_name);
262 let dep = format!(r#" .package(path: "{pkg_path}")"#);
263 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
264 (dep, prod)
265 }
266 };
267 let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
270 let min_ios = toolchain::SWIFT_MIN_IOS;
271 let min_ios_major = min_ios.split('.').next().unwrap_or(min_ios);
272 format!(
276 r#"// swift-tools-version: 6.0
277import PackageDescription
278
279let package = Package(
280 name: "E2eSwift",
281 platforms: [
282 .macOS(.v{min_macos_major}),
283 .iOS(.v{min_ios_major}),
284 ],
285 dependencies: [
286{dep_block},
287 ],
288 targets: [
289 .testTarget(
290 name: "{module_name}E2ETests",
291 dependencies: [{product_dep}]
292 ),
293 ]
294)
295"#
296 )
297}
298
299#[allow(clippy::too_many_arguments)]
300fn render_test_file(
301 category: &str,
302 fixtures: &[&Fixture],
303 e2e_config: &E2eConfig,
304 module_name: &str,
305 class_name: &str,
306 function_name: &str,
307 result_var: &str,
308 args: &[crate::config::ArgMapping],
309 result_is_simple: bool,
310 client_factory: Option<&str>,
311 swift_first_class_map: &SwiftFirstClassMap,
312) -> String {
313 let needs_chdir = fixtures.iter().any(|f| {
320 let call_config =
321 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
322 call_config
323 .args
324 .iter()
325 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
326 });
327
328 let mut out = String::new();
329 out.push_str(&hash::header(CommentStyle::DoubleSlash));
330 out.push_str(SWIFT_FORMAT_IGNORE_DIRECTIVE);
331 let _ = writeln!(out, "import XCTest");
332 let _ = writeln!(out, "import Foundation");
333 let _ = writeln!(out, "import {module_name}");
334 let _ = writeln!(out, "import RustBridge");
335 let _ = writeln!(out);
336 let _ = writeln!(out, "/// E2e tests for category: {category}.");
337 let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
338
339 if needs_chdir {
340 let _ = writeln!(out, " override class func setUp() {{");
348 let _ = writeln!(out, " super.setUp()");
349 let _ = writeln!(out, " let _testDocs = URL(fileURLWithPath: #filePath)");
350 let _ = writeln!(out, " .deletingLastPathComponent() // <Module>Tests/");
351 let _ = writeln!(out, " .deletingLastPathComponent() // Tests/");
352 let _ = writeln!(out, " .deletingLastPathComponent() // swift/");
353 let _ = writeln!(out, " .deletingLastPathComponent() // packages/");
354 let _ = writeln!(out, " .deletingLastPathComponent() // <repo root>");
355 let _ = writeln!(
356 out,
357 " .appendingPathComponent(\"{}\")",
358 e2e_config.test_documents_dir
359 );
360 let _ = writeln!(
361 out,
362 " if FileManager.default.fileExists(atPath: _testDocs.path) {{"
363 );
364 let _ = writeln!(
365 out,
366 " FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
367 );
368 let _ = writeln!(out, " }}");
369 let _ = writeln!(out, " }}");
370 let _ = writeln!(out);
371 }
372
373 for fixture in fixtures {
374 if fixture.is_http_test() {
375 render_http_test_method(&mut out, fixture);
376 } else {
377 render_test_method(
378 &mut out,
379 fixture,
380 e2e_config,
381 function_name,
382 result_var,
383 args,
384 result_is_simple,
385 client_factory,
386 swift_first_class_map,
387 );
388 }
389 let _ = writeln!(out);
390 }
391
392 let _ = writeln!(out, "}}");
393 out
394}
395
396struct SwiftTestClientRenderer;
403
404impl client::TestClientRenderer for SwiftTestClientRenderer {
405 fn language_name(&self) -> &'static str {
406 "swift"
407 }
408
409 fn sanitize_test_name(&self, id: &str) -> String {
410 sanitize_ident(id).to_upper_camel_case()
412 }
413
414 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
420 let _ = writeln!(out, " /// {description}");
421 let _ = writeln!(out, " func test{fn_name}() throws {{");
422 if let Some(reason) = skip_reason {
423 let escaped = escape_swift(reason);
424 let _ = writeln!(out, " try XCTSkipIf(true, \"{escaped}\")");
425 }
426 }
427
428 fn render_test_close(&self, out: &mut String) {
429 let _ = writeln!(out, " }}");
430 }
431
432 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
439 let method = ctx.method.to_uppercase();
440 let fixture_path = escape_swift(ctx.path);
441
442 let _ = writeln!(
443 out,
444 " let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
445 );
446 let _ = writeln!(
447 out,
448 " var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
449 );
450 let _ = writeln!(out, " _req.httpMethod = \"{method}\"");
451
452 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
454 header_pairs.sort_by_key(|(k, _)| k.as_str());
455 for (k, v) in &header_pairs {
456 let expanded_v = expand_fixture_templates(v);
457 let ek = escape_swift(k);
458 let ev = escape_swift(&expanded_v);
459 let _ = writeln!(out, " _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
460 }
461
462 if let Some(body) = ctx.body {
464 let json_str = serde_json::to_string(body).unwrap_or_default();
465 let escaped_body = escape_swift(&json_str);
466 let _ = writeln!(out, " _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
467 let _ = writeln!(
468 out,
469 " _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
470 );
471 }
472
473 let _ = writeln!(out, " var {}: HTTPURLResponse?", ctx.response_var);
474 let _ = writeln!(out, " var _responseData: Data?");
475 let _ = writeln!(out, " let _sema = DispatchSemaphore(value: 0)");
476 let _ = writeln!(
477 out,
478 " URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
479 );
480 let _ = writeln!(out, " {} = resp as? HTTPURLResponse", ctx.response_var);
481 let _ = writeln!(out, " _responseData = data");
482 let _ = writeln!(out, " _sema.signal()");
483 let _ = writeln!(out, " }}.resume()");
484 let _ = writeln!(out, " _sema.wait()");
485 let _ = writeln!(out, " let _resp = try XCTUnwrap({})", ctx.response_var);
486 }
487
488 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
489 let _ = writeln!(out, " XCTAssertEqual(_resp.statusCode, {status})");
490 }
491
492 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
493 let lower_name = name.to_lowercase();
494 let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
495 match expected {
496 "<<present>>" => {
497 let _ = writeln!(out, " XCTAssertNotNil({header_expr})");
498 }
499 "<<absent>>" => {
500 let _ = writeln!(out, " XCTAssertNil({header_expr})");
501 }
502 "<<uuid>>" => {
503 let _ = writeln!(out, " let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
504 let _ = writeln!(
505 out,
506 " 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))"
507 );
508 }
509 exact => {
510 let escaped = escape_swift(exact);
511 let _ = writeln!(out, " XCTAssertEqual({header_expr}, \"{escaped}\")");
512 }
513 }
514 }
515
516 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
517 if let serde_json::Value::String(s) = expected {
518 let escaped = escape_swift(s);
519 let _ = writeln!(
520 out,
521 " let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
522 );
523 let _ = writeln!(
524 out,
525 " XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
526 );
527 } else {
528 let json_str = serde_json::to_string(expected).unwrap_or_default();
529 let escaped = escape_swift(&json_str);
530 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
531 let _ = writeln!(
532 out,
533 " let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
534 );
535 let _ = writeln!(
536 out,
537 " let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
538 );
539 let _ = writeln!(
540 out,
541 " XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
542 );
543 }
544 }
545
546 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
547 if let Some(obj) = expected.as_object() {
548 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
549 let _ = writeln!(
550 out,
551 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
552 );
553 for (key, val) in obj {
554 let escaped_key = escape_swift(key);
555 let swift_val = json_to_swift(val);
556 let _ = writeln!(
557 out,
558 " XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
559 );
560 }
561 }
562 }
563
564 fn render_assert_validation_errors(
565 &self,
566 out: &mut String,
567 _response_var: &str,
568 errors: &[ValidationErrorExpectation],
569 ) {
570 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
571 let _ = writeln!(
572 out,
573 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
574 );
575 let _ = writeln!(
576 out,
577 " let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
578 );
579 for ve in errors {
580 let escaped_msg = escape_swift(&ve.msg);
581 let _ = writeln!(
582 out,
583 " XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
584 );
585 }
586 }
587}
588
589fn render_http_test_method(out: &mut String, fixture: &Fixture) {
594 let Some(http) = &fixture.http else {
595 return;
596 };
597
598 if http.expected_response.status_code == 101 {
600 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
601 let description = fixture.description.replace('"', "\\\"");
602 let _ = writeln!(out, " /// {description}");
603 let _ = writeln!(out, " func test{method_name}() throws {{");
604 let _ = writeln!(
605 out,
606 " try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
607 );
608 let _ = writeln!(out, " }}");
609 return;
610 }
611
612 client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
613}
614
615#[allow(clippy::too_many_arguments)]
620fn render_test_method(
621 out: &mut String,
622 fixture: &Fixture,
623 e2e_config: &E2eConfig,
624 _function_name: &str,
625 _result_var: &str,
626 _args: &[crate::config::ArgMapping],
627 result_is_simple: bool,
628 global_client_factory: Option<&str>,
629 swift_first_class_map: &SwiftFirstClassMap,
630) {
631 let call_config = e2e_config.resolve_call_for_fixture(
633 fixture.call.as_deref(),
634 &fixture.id,
635 &fixture.resolved_category(),
636 &fixture.tags,
637 &fixture.input,
638 );
639 let call_field_resolver = FieldResolver::new_with_swift_first_class(
641 e2e_config.effective_fields(call_config),
642 e2e_config.effective_fields_optional(call_config),
643 e2e_config.effective_result_fields(call_config),
644 e2e_config.effective_fields_array(call_config),
645 e2e_config.effective_fields_method_calls(call_config),
646 &HashMap::new(),
647 swift_first_class_map.clone(),
648 );
649 let field_resolver = &call_field_resolver;
650 let enum_fields = e2e_config.effective_fields_enum(call_config);
651 let lang = "swift";
652 let call_overrides = call_config.overrides.get(lang);
653 let function_name = call_overrides
654 .and_then(|o| o.function.as_ref())
655 .cloned()
656 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
657 let client_factory: Option<&str> = call_overrides
659 .and_then(|o| o.client_factory.as_deref())
660 .or(global_client_factory);
661 let result_var = &call_config.result_var;
662 let args = &call_config.args;
663 let result_is_bytes_any_lang =
670 call_config.result_is_bytes || call_config.overrides.values().any(|o| o.result_is_bytes);
671 let result_is_simple = call_config.result_is_simple
672 || call_overrides.is_some_and(|o| o.result_is_simple)
673 || result_is_simple
674 || result_is_bytes_any_lang;
675 let result_is_array = call_config.result_is_array;
676 let result_is_option = call_config.result_is_option || call_overrides.is_some_and(|o| o.result_is_option);
681
682 let method_name = fixture.id.to_upper_camel_case();
683 let description = &fixture.description;
684 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
685 let is_async = call_config.r#async;
686
687 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
689 let collect_snippet_opt = if is_streaming && !expects_error {
690 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(lang, result_var, "chunks")
691 } else {
692 None
693 };
694 if is_streaming && !expects_error && collect_snippet_opt.is_none() {
701 if is_async {
702 let _ = writeln!(out, " func test{method_name}() async throws {{");
703 } else {
704 let _ = writeln!(out, " func test{method_name}() throws {{");
705 }
706 let _ = writeln!(out, " // {description}");
707 let _ = writeln!(
708 out,
709 " try XCTSkipIf(true, \"swift: streaming chunk collection is not yet supported via the swift-bridge surface (fixture: {})\")",
710 fixture.id
711 );
712 let _ = writeln!(out, " }}");
713 return;
714 }
715 let collect_snippet = collect_snippet_opt.unwrap_or_default();
716
717 let has_unresolvable_json_object_arg = {
724 let options_via = call_overrides.and_then(|o| o.options_via.as_deref());
725 options_via.is_none() && args.iter().any(|a| a.arg_type == "json_object" && a.name != "config")
726 };
727
728 if has_unresolvable_json_object_arg {
729 if is_async {
730 let _ = writeln!(out, " func test{method_name}() async throws {{");
731 } else {
732 let _ = writeln!(out, " func test{method_name}() throws {{");
733 }
734 let _ = writeln!(out, " // {description}");
735 let _ = writeln!(
736 out,
737 " try XCTSkipIf(true, \"swift: json_object request construction requires options_via configuration (fixture: {})\");",
738 fixture.id
739 );
740 let _ = writeln!(out, " }}");
741 return;
742 }
743
744 let mut visitor_setup_lines: Vec<String> = Vec::new();
748 let visitor_handle_expr: Option<String> = fixture
749 .visitor
750 .as_ref()
751 .map(|spec| super::swift_visitors::build_swift_visitor(&mut visitor_setup_lines, spec, &fixture.id));
752
753 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
757
758 let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
763 let per_call = call_overrides.map(|o| &o.enum_fields);
764 if let Some(pc) = per_call {
765 if !pc.is_empty() {
766 let mut merged = enum_fields.clone();
767 merged.extend(pc.keys().cloned());
768 std::borrow::Cow::Owned(merged)
769 } else {
770 std::borrow::Cow::Borrowed(enum_fields)
771 }
772 } else {
773 std::borrow::Cow::Borrowed(enum_fields)
774 }
775 };
776
777 let options_via_str: Option<&str> = call_overrides.and_then(|o| o.options_via.as_deref());
778 let options_type_str: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
779 let handle_config_fn_owned: Option<String> = call_config
783 .overrides
784 .get("c")
785 .and_then(|c| c.c_engine_factory.as_deref())
786 .map(|ty| format!("{}_from_json", ty.to_snake_case()).to_lower_camel_case());
787 let (mut setup_lines, args_str) = build_args_and_setup(
788 &fixture.input,
789 args,
790 &fixture.id,
791 fixture.has_host_root_route(),
792 &function_name,
793 options_via_str,
794 options_type_str,
795 handle_config_fn_owned.as_deref(),
796 visitor_handle_expr.as_deref(),
797 );
798 if !visitor_setup_lines.is_empty() {
800 visitor_setup_lines.extend(setup_lines);
801 setup_lines = visitor_setup_lines;
802 }
803
804 let args_str = if extra_args.is_empty() {
806 args_str
807 } else if args_str.is_empty() {
808 extra_args.join(", ")
809 } else {
810 format!("{args_str}, {}", extra_args.join(", "))
811 };
812
813 let has_mock = fixture.mock_response.is_some();
818 let (call_setup, call_expr) = if let Some(_factory) = client_factory {
819 let env_key = format!("MOCK_SERVER_{}", fixture.id.to_ascii_uppercase().replace('-', "_"));
820 let mock_url = if fixture.has_host_root_route() {
821 format!(
822 "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\")",
823 fixture.id
824 )
825 } else {
826 format!(
827 "ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\"",
828 fixture.id
829 )
830 };
831 let client_constructor = if has_mock {
832 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
833 } else {
834 if let Some(env_var) = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()) {
836 format!(
837 "let _apiKey = ProcessInfo.processInfo.environment[\"{env_var}\"]\n \
838 let _baseUrl: String? = _apiKey != nil ? nil : {mock_url}\n \
839 let _client = try DefaultClient(apiKey: _apiKey ?? \"test-key\", baseUrl: _baseUrl)"
840 )
841 } else {
842 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
843 }
844 };
845 let expr = if is_async {
846 format!("try await _client.{function_name}({args_str})")
847 } else {
848 format!("try _client.{function_name}({args_str})")
849 };
850 (Some(client_constructor), expr)
851 } else {
852 let expr = if is_async {
854 format!("try await {function_name}({args_str})")
855 } else {
856 format!("try {function_name}({args_str})")
857 };
858 (None, expr)
859 };
860 let _ = function_name;
862
863 if is_async {
864 let _ = writeln!(out, " func test{method_name}() async throws {{");
865 } else {
866 let _ = writeln!(out, " func test{method_name}() throws {{");
867 }
868 let _ = writeln!(out, " // {description}");
869
870 if expects_error {
871 if is_async {
875 let _ = writeln!(out, " do {{");
880 for line in &setup_lines {
881 let _ = writeln!(out, " {line}");
882 }
883 if let Some(setup) = &call_setup {
884 let _ = writeln!(out, " {setup}");
885 }
886 let _ = writeln!(out, " _ = {call_expr}");
887 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
888 let _ = writeln!(out, " }} catch {{");
889 let _ = writeln!(out, " // success");
890 let _ = writeln!(out, " }}");
891 } else {
892 let _ = writeln!(out, " do {{");
899 for line in &setup_lines {
900 let _ = writeln!(out, " {line}");
901 }
902 if let Some(setup) = &call_setup {
903 let _ = writeln!(out, " {setup}");
904 }
905 let _ = writeln!(out, " _ = {call_expr}");
906 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
907 let _ = writeln!(out, " }} catch {{");
908 let _ = writeln!(out, " // success");
909 let _ = writeln!(out, " }}");
910 }
911 let _ = writeln!(out, " }}");
912 return;
913 }
914
915 for line in &setup_lines {
916 let _ = writeln!(out, " {line}");
917 }
918
919 if let Some(setup) = &call_setup {
921 let _ = writeln!(out, " {setup}");
922 }
923
924 let _ = writeln!(out, " let {result_var} = {call_expr}");
925
926 if !collect_snippet.is_empty() {
929 for line in collect_snippet.lines() {
930 let _ = writeln!(out, " {line}");
931 }
932 }
933
934 let fixture_root_type: Option<String> = swift_call_result_type(call_config);
939 let fixture_resolver = field_resolver.with_swift_root_type(fixture_root_type);
940
941 for assertion in &fixture.assertions {
942 render_assertion(
943 out,
944 assertion,
945 result_var,
946 &fixture_resolver,
947 result_is_simple,
948 result_is_array,
949 result_is_option,
950 &effective_enum_fields,
951 is_streaming,
952 );
953 }
954
955 let _ = writeln!(out, " }}");
956}
957
958#[allow(clippy::too_many_arguments)]
959fn build_args_and_setup(
973 input: &serde_json::Value,
974 args: &[crate::config::ArgMapping],
975 fixture_id: &str,
976 has_host_root_route: bool,
977 function_name: &str,
978 options_via: Option<&str>,
979 options_type: Option<&str>,
980 handle_config_fn: Option<&str>,
981 visitor_handle_expr: Option<&str>,
982) -> (Vec<String>, String) {
983 if args.is_empty() {
984 return (Vec::new(), String::new());
985 }
986
987 let mut setup_lines: Vec<String> = Vec::new();
988 let mut parts: Vec<String> = Vec::new();
989
990 let later_emits: Vec<bool> = (0..args.len())
995 .map(|i| {
996 args.iter().skip(i + 1).any(|a| {
997 let f = a.field.strip_prefix("input.").unwrap_or(&a.field);
998 let v = input.get(f);
999 let has_value = matches!(v, Some(x) if !x.is_null());
1000 has_value || !a.optional || (a.arg_type == "json_object" && a.name == "config")
1001 })
1002 })
1003 .collect();
1004
1005 for (idx, arg) in args.iter().enumerate() {
1006 if arg.arg_type == "mock_url" {
1007 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_ascii_uppercase().replace('-', "_"));
1008 let url_expr = if has_host_root_route {
1009 format!(
1010 "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\")"
1011 )
1012 } else {
1013 format!("ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"")
1014 };
1015 setup_lines.push(format!("let {} = {url_expr}", arg.name));
1016 parts.push(arg.name.clone());
1017 continue;
1018 }
1019
1020 if arg.arg_type == "handle" {
1021 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
1022 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1023 let config_val = input.get(field);
1024 let has_config = config_val
1025 .is_some_and(|v| !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty())));
1026 if has_config {
1027 if let Some(from_json_fn) = handle_config_fn {
1028 let json_str = serde_json::to_string(config_val.unwrap()).unwrap_or_default();
1029 let escaped = escape_swift_str(&json_str);
1030 let config_var = format!("{}Config", arg.name.to_lower_camel_case());
1031 setup_lines.push(format!("let {config_var} = try {from_json_fn}(\"{escaped}\")"));
1032 setup_lines.push(format!("let {var_name} = try createEngine({config_var})"));
1033 } else {
1034 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
1035 }
1036 } else {
1037 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
1038 }
1039 parts.push(var_name);
1040 continue;
1041 }
1042
1043 if arg.arg_type == "bytes" {
1048 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1049 let val = input.get(field);
1050 match val {
1051 None | Some(serde_json::Value::Null) if arg.optional => {
1052 if later_emits[idx] {
1053 parts.push("nil".to_string());
1054 }
1055 }
1056 None | Some(serde_json::Value::Null) => {
1057 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1058 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1059 parts.push(var_name);
1060 }
1061 Some(serde_json::Value::String(s)) => {
1062 let escaped = escape_swift(s);
1063 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1064 let data_var = format!("{}Data", arg.name.to_lower_camel_case());
1065 setup_lines.push(format!(
1066 "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
1067 ));
1068 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1069 setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
1070 parts.push(var_name);
1071 }
1072 Some(serde_json::Value::Array(arr)) => {
1073 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1074 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1075 for v in arr {
1076 if let Some(n) = v.as_u64() {
1077 setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
1078 }
1079 }
1080 parts.push(var_name);
1081 }
1082 Some(other) => {
1083 let json_str = serde_json::to_string(other).unwrap_or_default();
1085 let escaped = escape_swift(&json_str);
1086 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1087 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1088 setup_lines.push(format!(
1089 "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
1090 ));
1091 parts.push(var_name);
1092 }
1093 }
1094 continue;
1095 }
1096
1097 let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
1103 let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
1104 if is_config_arg && !is_batch_fn {
1105 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1106 let val = input.get(field);
1107 let json_str = match val {
1108 None | Some(serde_json::Value::Null) => "{}".to_string(),
1109 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1110 };
1111 let escaped = escape_swift(&json_str);
1112 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
1113 let from_json_fn = if let Some(type_name) = options_type {
1115 format!("{}FromJson", type_name.to_lower_camel_case())
1116 } else {
1117 "extractionConfigFromJson".to_string()
1118 };
1119 setup_lines.push(format!("let {var_name} = try {from_json_fn}(\"{escaped}\")"));
1120 parts.push(var_name);
1121 continue;
1122 }
1123
1124 if arg.arg_type == "json_object" && options_via == Some("from_json") {
1131 if let Some(type_name) = options_type {
1132 let resolved_val = super::resolve_field(input, &arg.field);
1133 let json_str = match resolved_val {
1134 serde_json::Value::Null => "{}".to_string(),
1135 v => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1136 };
1137 let escaped = escape_swift(&json_str);
1138 let var_name = format!("_{}", arg.name.to_lower_camel_case());
1139 if let Some(handle_expr) = visitor_handle_expr {
1140 let with_visitor_fn = format!("{}FromJsonWithVisitor", type_name.to_lower_camel_case());
1145 let handle_var = format!("_visitorHandle_{}", var_name.trim_start_matches('_'));
1146 setup_lines.push(format!("let {handle_var} = {handle_expr}"));
1147 setup_lines.push(format!(
1148 "let {var_name} = try {with_visitor_fn}(\"{escaped}\", {handle_var})"
1149 ));
1150 } else {
1151 let from_json_fn = format!("{}FromJson", type_name.to_lower_camel_case());
1152 setup_lines.push(format!("let {var_name} = try {from_json_fn}(\"{escaped}\")"));
1153 }
1154 parts.push(var_name);
1155 continue;
1156 }
1157 }
1158
1159 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1160 let val = input.get(field);
1161 match val {
1162 None | Some(serde_json::Value::Null) if arg.optional => {
1163 if later_emits[idx] {
1167 parts.push("nil".to_string());
1168 }
1169 }
1170 None | Some(serde_json::Value::Null) => {
1171 let default_val = match arg.arg_type.as_str() {
1172 "string" => "\"\"".to_string(),
1173 "int" | "integer" => "0".to_string(),
1174 "float" | "number" => "0.0".to_string(),
1175 "bool" | "boolean" => "false".to_string(),
1176 _ => "nil".to_string(),
1177 };
1178 parts.push(default_val);
1179 }
1180 Some(v) => {
1181 parts.push(json_to_swift(v));
1182 }
1183 }
1184 }
1185
1186 (setup_lines, parts.join(", "))
1187}
1188
1189#[allow(clippy::too_many_arguments)]
1190fn render_assertion(
1191 out: &mut String,
1192 assertion: &Assertion,
1193 result_var: &str,
1194 field_resolver: &FieldResolver,
1195 result_is_simple: bool,
1196 result_is_array: bool,
1197 result_is_option: bool,
1198 enum_fields: &HashSet<String>,
1199 is_streaming: bool,
1200) {
1201 let bare_result_is_option = result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1206 if let Some(f) = &assertion.field {
1211 let is_streaming_usage_path =
1212 is_streaming && (f == "usage" || (f.starts_with("usage.") || f.starts_with("usage[")));
1213 if !f.is_empty()
1214 && (crate::codegen::streaming_assertions::is_streaming_virtual_field(f) || is_streaming_usage_path)
1215 {
1216 if let Some(expr) =
1217 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "swift", "chunks")
1218 {
1219 let line = match assertion.assertion_type.as_str() {
1220 "count_min" => {
1221 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1222 format!(" XCTAssertGreaterThanOrEqual(chunks.count, {n})\n")
1223 } else {
1224 String::new()
1225 }
1226 }
1227 "count_equals" => {
1228 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1229 format!(" XCTAssertEqual(chunks.count, {n})\n")
1230 } else {
1231 String::new()
1232 }
1233 }
1234 "equals" => {
1235 if let Some(serde_json::Value::String(s)) = &assertion.value {
1236 let escaped = escape_swift(s);
1237 format!(" XCTAssertEqual({expr}, \"{escaped}\")\n")
1238 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1239 format!(" XCTAssertEqual({expr}, {b})\n")
1240 } else {
1241 String::new()
1242 }
1243 }
1244 "not_empty" => {
1245 format!(" XCTAssertFalse({expr}.isEmpty, \"expected non-empty\")\n")
1246 }
1247 "is_empty" => {
1248 format!(" XCTAssertTrue({expr}.isEmpty, \"expected empty\")\n")
1249 }
1250 "is_true" => {
1251 format!(" XCTAssertTrue({expr})\n")
1252 }
1253 "is_false" => {
1254 format!(" XCTAssertFalse({expr})\n")
1255 }
1256 "greater_than" => {
1257 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1258 format!(" XCTAssertGreaterThan(chunks.count, {n})\n")
1259 } else {
1260 String::new()
1261 }
1262 }
1263 "contains" => {
1264 if let Some(serde_json::Value::String(s)) = &assertion.value {
1265 let escaped = escape_swift(s);
1266 format!(
1267 " XCTAssertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1268 )
1269 } else {
1270 String::new()
1271 }
1272 }
1273 _ => format!(
1274 " // streaming field '{f}': assertion type '{}' not rendered\n",
1275 assertion.assertion_type
1276 ),
1277 };
1278 if !line.is_empty() {
1279 out.push_str(&line);
1280 }
1281 }
1282 return;
1283 }
1284 }
1285
1286 if let Some(f) = &assertion.field {
1288 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1289 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1290 return;
1291 }
1292 }
1293
1294 if let Some(f) = &assertion.field {
1299 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1300 let _ = writeln!(
1301 out,
1302 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
1303 );
1304 return;
1305 }
1306 }
1307
1308 let field_is_enum = assertion
1310 .field
1311 .as_deref()
1312 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1313
1314 let field_is_optional = assertion.field.as_deref().is_some_and(|f| {
1315 !f.is_empty() && (field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f)))
1316 });
1317 let field_is_array = assertion.field.as_deref().is_some_and(|f| {
1318 !f.is_empty()
1319 && (field_resolver.is_array(f)
1320 || field_resolver.is_array(field_resolver.resolve(f))
1321 || field_resolver.is_collection_root(f)
1322 || field_resolver.is_collection_root(field_resolver.resolve(f)))
1323 });
1324
1325 let field_expr_raw = if result_is_simple {
1326 result_var.to_string()
1327 } else {
1328 match &assertion.field {
1329 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
1330 _ => result_var.to_string(),
1331 }
1332 };
1333
1334 let local_suffix = {
1344 use std::hash::{Hash, Hasher};
1345 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1346 assertion.field.hash(&mut hasher);
1347 assertion
1348 .value
1349 .as_ref()
1350 .map(|v| v.to_string())
1351 .unwrap_or_default()
1352 .hash(&mut hasher);
1353 format!(
1354 "{}_{:x}",
1355 assertion.assertion_type.replace(['-', '.'], "_"),
1356 hasher.finish() & 0xffff_ffff,
1357 )
1358 };
1359 let (vec_setup, field_expr, is_map_subscript) = materialise_vec_temporaries(&field_expr_raw, &local_suffix);
1360 let field_uses_traversal = assertion.field.as_deref().is_some_and(|f| f.contains("[]."));
1365 let traversal_skips_field_expr = field_uses_traversal
1366 && matches!(
1367 assertion.assertion_type.as_str(),
1368 "contains" | "not_contains" | "not_empty" | "is_empty"
1369 );
1370 if !traversal_skips_field_expr {
1371 for line in &vec_setup {
1372 let _ = writeln!(out, " {line}");
1373 }
1374 }
1375
1376 let accessor_is_optional = field_expr.contains("?.");
1382
1383 let string_expr = if is_map_subscript {
1392 format!("({field_expr} ?? \"\")")
1396 } else if field_is_enum && (field_is_optional || accessor_is_optional) {
1397 format!("({field_expr}?.toString() ?? \"\")")
1400 } else if field_is_enum {
1401 format!("{field_expr}.toString()")
1406 } else if field_is_optional {
1407 format!("({field_expr}?.toString() ?? \"\")")
1409 } else if accessor_is_optional {
1410 format!("({field_expr}.toString() ?? \"\")")
1413 } else {
1414 format!("{field_expr}.toString()")
1415 };
1416
1417 match assertion.assertion_type.as_str() {
1418 "equals" => {
1419 if let Some(expected) = &assertion.value {
1420 let swift_val = json_to_swift(expected);
1421 if expected.is_string() {
1422 if field_is_enum {
1423 let trim_expr =
1427 format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)");
1428 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1429 } else {
1430 let trim_expr =
1435 format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)");
1436 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1437 }
1438 } else {
1439 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
1440 }
1441 }
1442 }
1443 "contains" => {
1444 if let Some(expected) = &assertion.value {
1445 let swift_val = json_to_swift(expected);
1446 let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
1449 if result_is_simple && result_is_array && no_field {
1450 let _ = writeln!(
1453 out,
1454 " XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1455 );
1456 } else {
1457 let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1459 if let Some(dot) = f.find("[].") {
1460 let array_part = &f[..dot];
1461 let elem_part = &f[dot + 3..];
1462 let line = swift_traversal_contains_assert(
1463 array_part,
1464 elem_part,
1465 f,
1466 &swift_val,
1467 result_var,
1468 false,
1469 &format!("expected to contain: \\({swift_val})"),
1470 enum_fields,
1471 field_resolver,
1472 );
1473 let _ = writeln!(out, "{line}");
1474 true
1475 } else {
1476 false
1477 }
1478 } else {
1479 false
1480 };
1481 if !traversal_handled {
1482 let field_is_array = assertion
1484 .field
1485 .as_deref()
1486 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1487 if field_is_array {
1488 let contains_expr =
1489 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1490 let _ = writeln!(
1491 out,
1492 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1493 );
1494 } else if field_is_enum {
1495 let _ = writeln!(
1498 out,
1499 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1500 );
1501 } else {
1502 let _ = writeln!(
1503 out,
1504 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1505 );
1506 }
1507 }
1508 }
1509 }
1510 }
1511 "contains_all" => {
1512 if let Some(values) = &assertion.values {
1513 if let Some(f) = assertion.field.as_deref() {
1515 if let Some(dot) = f.find("[].") {
1516 let array_part = &f[..dot];
1517 let elem_part = &f[dot + 3..];
1518 for val in values {
1519 let swift_val = json_to_swift(val);
1520 let line = swift_traversal_contains_assert(
1521 array_part,
1522 elem_part,
1523 f,
1524 &swift_val,
1525 result_var,
1526 false,
1527 &format!("expected to contain: \\({swift_val})"),
1528 enum_fields,
1529 field_resolver,
1530 );
1531 let _ = writeln!(out, "{line}");
1532 }
1533 } else {
1535 let field_is_array = field_resolver.is_array(field_resolver.resolve(f));
1537 if field_is_array {
1538 let contains_expr =
1539 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1540 for val in values {
1541 let swift_val = json_to_swift(val);
1542 let _ = writeln!(
1543 out,
1544 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1545 );
1546 }
1547 } else if field_is_enum {
1548 for val in values {
1551 let swift_val = json_to_swift(val);
1552 let _ = writeln!(
1553 out,
1554 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1555 );
1556 }
1557 } else {
1558 for val in values {
1559 let swift_val = json_to_swift(val);
1560 let _ = writeln!(
1561 out,
1562 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1563 );
1564 }
1565 }
1566 }
1567 } else {
1568 for val in values {
1570 let swift_val = json_to_swift(val);
1571 let _ = writeln!(
1572 out,
1573 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1574 );
1575 }
1576 }
1577 }
1578 }
1579 "not_contains" => {
1580 if let Some(expected) = &assertion.value {
1581 let swift_val = json_to_swift(expected);
1582 let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1584 if let Some(dot) = f.find("[].") {
1585 let array_part = &f[..dot];
1586 let elem_part = &f[dot + 3..];
1587 let line = swift_traversal_contains_assert(
1588 array_part,
1589 elem_part,
1590 f,
1591 &swift_val,
1592 result_var,
1593 true,
1594 &format!("expected NOT to contain: \\({swift_val})"),
1595 enum_fields,
1596 field_resolver,
1597 );
1598 let _ = writeln!(out, "{line}");
1599 true
1600 } else {
1601 false
1602 }
1603 } else {
1604 false
1605 };
1606 if !traversal_handled {
1607 let _ = writeln!(
1608 out,
1609 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
1610 );
1611 }
1612 }
1613 }
1614 "not_empty" => {
1615 let traversal_not_empty_handled = if let Some(f) = assertion.field.as_deref() {
1622 if let Some(dot) = f.find("[].") {
1623 let array_part = &f[..dot];
1624 let elem_part = &f[dot + 3..];
1625 let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1626 let resolved_full = field_resolver.resolve(f);
1627 let resolved_elem_part = resolved_full
1628 .find("[].")
1629 .map(|d| &resolved_full[d + 3..])
1630 .unwrap_or(elem_part);
1631 let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1632 let elem_is_enum = enum_fields.contains(f) || enum_fields.contains(resolved_full);
1633 let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1634 || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1635 let elem_str = if elem_is_enum {
1636 format!("{elem_accessor}.to_string().toString()")
1637 } else if elem_is_optional {
1638 format!("({elem_accessor}?.toString() ?? \"\")")
1639 } else {
1640 format!("{elem_accessor}.toString()")
1641 };
1642 let _ = writeln!(
1643 out,
1644 " XCTAssertTrue({array_accessor}.contains(where: {{ !{elem_str}.isEmpty }}), \"expected non-empty value\")"
1645 );
1646 true
1647 } else {
1648 false
1649 }
1650 } else {
1651 false
1652 };
1653 if !traversal_not_empty_handled {
1654 if bare_result_is_option {
1655 let _ = writeln!(out, " XCTAssertNotNil({result_var}, \"expected non-nil value\")");
1656 } else if field_is_optional {
1657 let _ = writeln!(out, " XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
1658 } else if field_is_array {
1659 let _ = writeln!(
1660 out,
1661 " XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
1662 );
1663 } else if result_is_simple {
1664 let _ = writeln!(
1666 out,
1667 " XCTAssertFalse({result_var}.isEmpty, \"expected non-empty value\")"
1668 );
1669 } else {
1670 let count_target = swift_count_target(&field_expr, field_resolver, assertion.field.as_deref());
1685 let len_expr = if accessor_is_optional {
1686 format!("({count_target}.count ?? 0)")
1687 } else {
1688 format!("{count_target}.count")
1689 };
1690 let _ = writeln!(
1691 out,
1692 " XCTAssertGreaterThan({len_expr}, 0, \"expected non-empty value\")"
1693 );
1694 }
1695 }
1696 }
1697 "is_empty" => {
1698 if bare_result_is_option {
1699 let _ = writeln!(out, " XCTAssertNil({result_var}, \"expected nil value\")");
1700 } else if field_is_optional {
1701 let _ = writeln!(out, " XCTAssertNil({field_expr}, \"expected nil value\")");
1702 } else if field_is_array {
1703 let _ = writeln!(
1704 out,
1705 " XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
1706 );
1707 } else {
1708 let count_target = swift_count_target(&field_expr, field_resolver, assertion.field.as_deref());
1712 let len_expr = if accessor_is_optional {
1713 format!("({count_target}.count ?? 0)")
1714 } else {
1715 format!("{count_target}.count")
1716 };
1717 let _ = writeln!(out, " XCTAssertEqual({len_expr}, 0, \"expected empty value\")");
1718 }
1719 }
1720 "contains_any" => {
1721 if let Some(values) = &assertion.values {
1722 let checks: Vec<String> = values
1723 .iter()
1724 .map(|v| {
1725 let swift_val = json_to_swift(v);
1726 format!("{string_expr}.contains({swift_val})")
1727 })
1728 .collect();
1729 let joined = checks.join(" || ");
1730 let _ = writeln!(
1731 out,
1732 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
1733 );
1734 }
1735 }
1736 "greater_than" => {
1737 if let Some(val) = &assertion.value {
1738 let swift_val = json_to_swift(val);
1739 let field_is_optional = accessor_is_optional
1742 || assertion.field.as_deref().is_some_and(|f| {
1743 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1744 });
1745 let compare_expr = if field_is_optional {
1746 format!("({field_expr} ?? 0)")
1747 } else {
1748 field_expr.clone()
1749 };
1750 let _ = writeln!(out, " XCTAssertGreaterThan({compare_expr}, {swift_val})");
1751 }
1752 }
1753 "less_than" => {
1754 if let Some(val) = &assertion.value {
1755 let swift_val = json_to_swift(val);
1756 let field_is_optional = accessor_is_optional
1757 || assertion.field.as_deref().is_some_and(|f| {
1758 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1759 });
1760 let compare_expr = if field_is_optional {
1761 format!("({field_expr} ?? 0)")
1762 } else {
1763 field_expr.clone()
1764 };
1765 let _ = writeln!(out, " XCTAssertLessThan({compare_expr}, {swift_val})");
1766 }
1767 }
1768 "greater_than_or_equal" => {
1769 if let Some(val) = &assertion.value {
1770 let swift_val = json_to_swift(val);
1771 let field_is_optional = accessor_is_optional
1774 || assertion.field.as_deref().is_some_and(|f| {
1775 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1776 });
1777 let compare_expr = if field_is_optional {
1778 format!("({field_expr} ?? 0)")
1779 } else {
1780 field_expr.clone()
1781 };
1782 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
1783 }
1784 }
1785 "less_than_or_equal" => {
1786 if let Some(val) = &assertion.value {
1787 let swift_val = json_to_swift(val);
1788 let field_is_optional = accessor_is_optional
1789 || assertion.field.as_deref().is_some_and(|f| {
1790 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1791 });
1792 let compare_expr = if field_is_optional {
1793 format!("({field_expr} ?? 0)")
1794 } else {
1795 field_expr.clone()
1796 };
1797 let _ = writeln!(out, " XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
1798 }
1799 }
1800 "starts_with" => {
1801 if let Some(expected) = &assertion.value {
1802 let swift_val = json_to_swift(expected);
1803 let _ = writeln!(
1804 out,
1805 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1806 );
1807 }
1808 }
1809 "ends_with" => {
1810 if let Some(expected) = &assertion.value {
1811 let swift_val = json_to_swift(expected);
1812 let _ = writeln!(
1813 out,
1814 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1815 );
1816 }
1817 }
1818 "min_length" => {
1819 if let Some(val) = &assertion.value {
1820 if let Some(n) = val.as_u64() {
1821 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1824 }
1825 }
1826 }
1827 "max_length" => {
1828 if let Some(val) = &assertion.value {
1829 if let Some(n) = val.as_u64() {
1830 let _ = writeln!(out, " XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1831 }
1832 }
1833 }
1834 "count_min" => {
1835 if let Some(val) = &assertion.value {
1836 if let Some(n) = val.as_u64() {
1837 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1841 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1842 }
1843 }
1844 }
1845 "count_equals" => {
1846 if let Some(val) = &assertion.value {
1847 if let Some(n) = val.as_u64() {
1848 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1849 let _ = writeln!(out, " XCTAssertEqual({count_expr}, {n})");
1850 }
1851 }
1852 }
1853 "is_true" => {
1854 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
1855 }
1856 "is_false" => {
1857 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
1858 }
1859 "matches_regex" => {
1860 if let Some(expected) = &assertion.value {
1861 let swift_val = json_to_swift(expected);
1862 let _ = writeln!(
1863 out,
1864 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1865 );
1866 }
1867 }
1868 "not_error" => {
1869 }
1871 "error" => {
1872 }
1874 "method_result" => {
1875 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
1876 }
1877 other => {
1878 panic!("Swift e2e generator: unsupported assertion type: {other}");
1879 }
1880 }
1881}
1882
1883fn materialise_vec_temporaries(expr: &str, name_suffix: &str) -> (Vec<String>, String, bool) {
1908 let Some(idx) = expr.find("()[") else {
1909 return (Vec::new(), expr.to_string(), false);
1910 };
1911 let after_open = idx + 3; let Some(close_rel) = expr[after_open..].find(']') else {
1913 return (Vec::new(), expr.to_string(), false);
1914 };
1915 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);
1920 let method = &expr[method_dot + 1..idx];
1921 let local = format!("_vec_{}_{}", method, name_suffix);
1922
1923 let inner = subscript.trim_start_matches('[').trim_end_matches(']');
1928 let is_string_key = inner.starts_with('"') && inner.ends_with('"');
1929 let setup = if is_string_key {
1930 format!(
1931 "let {local} = (try? JSONSerialization.jsonObject(with: ({prefix}.toString() ?? \"{{}}\").data(using: .utf8)!) as? [String: String]) ?? [:]"
1932 )
1933 } else {
1934 format!("let {local} = {prefix}")
1935 };
1936
1937 let rewritten = format!("{local}{subscript}{tail}");
1938 (vec![setup], rewritten, is_string_key)
1939}
1940
1941fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1944 let resolved = field_resolver.resolve(field);
1945 let parts: Vec<&str> = resolved.split('.').collect();
1946
1947 let mut out = result_var.to_string();
1950 let mut has_optional = false;
1951 let mut path_so_far = String::new();
1952 let total = parts.len();
1953 for (i, part) in parts.iter().enumerate() {
1954 let is_leaf = i == total - 1;
1955 let (field_name, subscript): (&str, Option<&str>) = if let Some(bracket_pos) = part.find('[') {
1959 (&part[..bracket_pos], Some(&part[bracket_pos..]))
1960 } else {
1961 (part, None)
1962 };
1963
1964 if !path_so_far.is_empty() {
1965 path_so_far.push('.');
1966 }
1967 let base_path = {
1971 let mut p = path_so_far.clone();
1972 p.push_str(field_name);
1973 p
1974 };
1975 path_so_far.push_str(part);
1978
1979 out.push('.');
1980 out.push_str(field_name);
1981 if let Some(sub) = subscript {
1982 let field_is_optional = field_resolver.is_optional(&base_path);
1986 if field_is_optional {
1987 out.push_str("()?");
1988 has_optional = true;
1989 } else {
1990 out.push_str("()");
1991 }
1992 out.push_str(sub);
1993 } else {
2003 out.push_str("()");
2004 if !is_leaf && field_resolver.is_optional(&base_path) {
2007 out.push('?');
2008 has_optional = true;
2009 }
2010 }
2011 }
2012 (out, has_optional)
2013}
2014
2015#[allow(clippy::too_many_arguments)]
2037fn swift_traversal_contains_assert(
2038 array_part: &str,
2039 element_part: &str,
2040 full_field: &str,
2041 val_expr: &str,
2042 result_var: &str,
2043 negate: bool,
2044 msg: &str,
2045 enum_fields: &std::collections::HashSet<String>,
2046 field_resolver: &FieldResolver,
2047) -> String {
2048 let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
2049 let resolved_full = field_resolver.resolve(full_field);
2050 let resolved_elem_part = resolved_full
2051 .find("[].")
2052 .map(|d| &resolved_full[d + 3..])
2053 .unwrap_or(element_part);
2054 let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
2055 let elem_is_enum = enum_fields.contains(full_field) || enum_fields.contains(resolved_full);
2056 let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
2057 || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
2058 let elem_str = if elem_is_enum {
2059 format!("{elem_accessor}.toString()")
2062 } else if elem_is_optional {
2063 format!("({elem_accessor}?.toString() ?? \"\")")
2064 } else {
2065 format!("{elem_accessor}.toString()")
2066 };
2067 let assert_fn = if negate { "XCTAssertFalse" } else { "XCTAssertTrue" };
2068 format!(" {assert_fn}({array_accessor}.contains(where: {{ {elem_str}.contains({val_expr}) }}), \"{msg}\")")
2069}
2070
2071fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
2072 let Some(f) = field else {
2073 return format!("{result_var}.map {{ $0.as_str().toString() }}");
2074 };
2075 let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
2076 format!("{accessor}?.map {{ $0.as_str().toString() }}")
2079}
2080
2081fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
2090 let Some(f) = field else {
2091 return format!("{result_var}.count");
2092 };
2093 let (accessor, mut has_optional) = swift_build_accessor(f, result_var, field_resolver);
2094 if field_resolver.is_optional(f) {
2096 has_optional = true;
2097 }
2098 if has_optional {
2099 if accessor.contains("?.") {
2102 format!("{accessor}.count ?? 0")
2103 } else {
2104 format!("({accessor}?.count ?? 0)")
2107 }
2108 } else {
2109 format!("{accessor}.count")
2110 }
2111}
2112
2113fn json_to_swift(value: &serde_json::Value) -> String {
2115 match value {
2116 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
2117 serde_json::Value::Bool(b) => b.to_string(),
2118 serde_json::Value::Number(n) => n.to_string(),
2119 serde_json::Value::Null => "nil".to_string(),
2120 serde_json::Value::Array(arr) => {
2121 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
2122 format!("[{}]", items.join(", "))
2123 }
2124 serde_json::Value::Object(_) => {
2125 let json_str = serde_json::to_string(value).unwrap_or_default();
2126 format!("\"{}\"", escape_swift(&json_str))
2127 }
2128 }
2129}
2130
2131fn escape_swift(s: &str) -> String {
2133 escape_swift_str(s)
2134}
2135
2136fn swift_count_target(field_expr: &str, field_resolver: &FieldResolver, field: Option<&str>) -> String {
2153 let is_method_call = field_expr.trim_end().ends_with(')');
2154 if !is_method_call {
2155 return field_expr.to_string();
2156 }
2157 if let Some(f) = field
2158 && field_resolver.leaf_is_vec_via_swift_map(field_resolver.resolve(f))
2159 {
2160 return field_expr.to_string();
2161 }
2162 format!("{field_expr}.toString()")
2163}
2164
2165fn swift_call_result_type(call_config: &alef_core::config::e2e::CallConfig) -> Option<String> {
2179 const LOOKUP_LANGS: &[&str] = &["c", "csharp", "java", "kotlin", "go", "php"];
2180 for lang in LOOKUP_LANGS {
2181 if let Some(o) = call_config.overrides.get(*lang)
2182 && let Some(rt) = o.result_type.as_deref()
2183 && !rt.is_empty()
2184 {
2185 return Some(rt.to_string());
2186 }
2187 }
2188 None
2189}
2190
2191fn swift_first_class_field_supported(ty: &alef_core::ir::TypeRef) -> bool {
2196 use alef_core::ir::TypeRef;
2197 match ty {
2198 TypeRef::Primitive(_) | TypeRef::String => true,
2199 TypeRef::Optional(inner) => swift_first_class_field_supported(inner),
2200 _ => false,
2201 }
2202}
2203
2204fn build_swift_first_class_map(
2217 type_defs: &[alef_core::ir::TypeDef],
2218 e2e_config: &crate::config::E2eConfig,
2219) -> SwiftFirstClassMap {
2220 use alef_core::ir::TypeRef;
2221 let mut first_class_types: HashSet<String> = HashSet::new();
2222 let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2223 let mut vec_field_names: HashSet<String> = HashSet::new();
2224 fn inner_named(ty: &TypeRef) -> Option<String> {
2225 match ty {
2226 TypeRef::Named(n) => Some(n.clone()),
2227 TypeRef::Optional(inner) | TypeRef::Vec(inner) => inner_named(inner),
2228 _ => None,
2229 }
2230 }
2231 fn is_vec_ty(ty: &TypeRef) -> bool {
2232 match ty {
2233 TypeRef::Vec(_) => true,
2234 TypeRef::Optional(inner) => is_vec_ty(inner),
2235 _ => false,
2236 }
2237 }
2238 for td in type_defs {
2239 let is_first_class = !td.is_opaque
2240 && td.has_serde
2241 && !td.fields.is_empty()
2242 && td.fields.iter().all(|f| swift_first_class_field_supported(&f.ty));
2243 if is_first_class {
2244 first_class_types.insert(td.name.clone());
2245 }
2246 let mut td_field_types: HashMap<String, String> = HashMap::new();
2247 for f in &td.fields {
2248 if let Some(named) = inner_named(&f.ty) {
2249 td_field_types.insert(f.name.clone(), named);
2250 }
2251 if is_vec_ty(&f.ty) {
2252 vec_field_names.insert(f.name.clone());
2253 }
2254 }
2255 if !td_field_types.is_empty() {
2256 field_types.insert(td.name.clone(), td_field_types);
2257 }
2258 }
2259 let root_type = if e2e_config.result_fields.is_empty() {
2263 None
2264 } else {
2265 let matches: Vec<&alef_core::ir::TypeDef> = type_defs
2266 .iter()
2267 .filter(|td| {
2268 let names: HashSet<&str> = td.fields.iter().map(|f| f.name.as_str()).collect();
2269 e2e_config.result_fields.iter().all(|rf| names.contains(rf.as_str()))
2270 })
2271 .collect();
2272 if matches.len() == 1 {
2273 Some(matches[0].name.clone())
2274 } else {
2275 None
2276 }
2277 };
2278 SwiftFirstClassMap {
2279 first_class_types,
2280 field_types,
2281 vec_field_names,
2282 root_type,
2283 }
2284}
2285
2286#[cfg(test)]
2287mod tests {
2288 use super::*;
2289 use crate::field_access::FieldResolver;
2290 use std::collections::{HashMap, HashSet};
2291
2292 fn make_resolver_tool_calls() -> FieldResolver {
2293 let mut optional = HashSet::new();
2297 optional.insert("choices.message.tool_calls".to_string());
2298 let mut arrays = HashSet::new();
2299 arrays.insert("choices".to_string());
2300 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
2301 }
2302
2303 #[test]
2310 fn optional_vec_subscript_does_not_emit_trailing_question_mark_before_next_segment() {
2311 let resolver = make_resolver_tool_calls();
2312 let (accessor, has_optional) =
2315 swift_build_accessor("choices[0].message.tool_calls[0].function.name", "result", &resolver);
2316 assert!(
2319 accessor.contains("tool_calls()?[0]"),
2320 "expected `tool_calls()?[0]` for optional tool_calls, got: {accessor}"
2321 );
2322 assert!(
2324 !accessor.contains("?[0]?"),
2325 "must not emit trailing `?` after subscript index: {accessor}"
2326 );
2327 assert!(has_optional, "expected has_optional=true for optional field chain");
2329 assert!(
2331 accessor.contains("[0].function()"),
2332 "expected `.function()` (non-optional) after subscript: {accessor}"
2333 );
2334 }
2335}