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 field_resolver = FieldResolver::new_with_swift_first_class(
119 &e2e_config.fields,
120 &e2e_config.fields_optional,
121 &e2e_config.result_fields,
122 &e2e_config.fields_array,
123 &e2e_config.fields_method_calls,
124 &HashMap::new(),
125 swift_first_class_map,
126 );
127
128 let client_factory: Option<&str> = overrides.and_then(|o| o.client_factory.as_deref());
130
131 for group in groups {
133 let active: Vec<&Fixture> = group
134 .fixtures
135 .iter()
136 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
137 .collect();
138
139 if active.is_empty() {
140 continue;
141 }
142
143 let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
144 let filename = format!("{class_name}.swift");
145 let content = render_test_file(
146 &group.category,
147 &active,
148 e2e_config,
149 module_name,
150 &class_name,
151 &function_name,
152 result_var,
153 &e2e_config.call.args,
154 &field_resolver,
155 result_is_simple,
156 &e2e_config.fields_enum,
157 client_factory,
158 );
159 files.push(GeneratedFile {
160 path: tests_base
161 .join("Tests")
162 .join(format!("{module_name}E2ETests"))
163 .join(filename),
164 content,
165 generated_header: true,
166 });
167 }
168
169 Ok(files)
170 }
171
172 fn language_name(&self) -> &'static str {
173 "swift"
174 }
175}
176
177fn render_package_swift(
182 module_name: &str,
183 registry_url: &str,
184 pkg_path: &str,
185 pkg_version: &str,
186 dep_mode: crate::config::DependencyMode,
187) -> String {
188 let min_macos = toolchain::SWIFT_MIN_MACOS;
189
190 let (dep_block, product_dep) = match dep_mode {
194 crate::config::DependencyMode::Registry => {
195 let dep = format!(r#" .package(url: "{registry_url}", from: "{pkg_version}")"#);
196 let pkg_id = registry_url
197 .trim_end_matches('/')
198 .trim_end_matches(".git")
199 .split('/')
200 .next_back()
201 .unwrap_or(module_name);
202 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
203 (dep, prod)
204 }
205 crate::config::DependencyMode::Local => {
206 let pkg_id = pkg_path.trim_end_matches('/').rsplit('/').next().unwrap_or(module_name);
213 let dep = format!(r#" .package(path: "{pkg_path}")"#);
214 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
215 (dep, prod)
216 }
217 };
218 let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
221 let min_ios = toolchain::SWIFT_MIN_IOS;
222 let min_ios_major = min_ios.split('.').next().unwrap_or(min_ios);
223 format!(
227 r#"// swift-tools-version: 6.0
228import PackageDescription
229
230let package = Package(
231 name: "E2eSwift",
232 platforms: [
233 .macOS(.v{min_macos_major}),
234 .iOS(.v{min_ios_major}),
235 ],
236 dependencies: [
237{dep_block},
238 ],
239 targets: [
240 .testTarget(
241 name: "{module_name}E2ETests",
242 dependencies: [{product_dep}]
243 ),
244 ]
245)
246"#
247 )
248}
249
250#[allow(clippy::too_many_arguments)]
251fn render_test_file(
252 category: &str,
253 fixtures: &[&Fixture],
254 e2e_config: &E2eConfig,
255 module_name: &str,
256 class_name: &str,
257 function_name: &str,
258 result_var: &str,
259 args: &[crate::config::ArgMapping],
260 field_resolver: &FieldResolver,
261 result_is_simple: bool,
262 enum_fields: &HashSet<String>,
263 client_factory: Option<&str>,
264) -> String {
265 let needs_chdir = fixtures.iter().any(|f| {
272 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
273 call_config
274 .args
275 .iter()
276 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
277 });
278
279 let mut out = String::new();
280 out.push_str(&hash::header(CommentStyle::DoubleSlash));
281 let _ = writeln!(out, "import XCTest");
282 let _ = writeln!(out, "import Foundation");
283 let _ = writeln!(out, "import {module_name}");
284 let _ = writeln!(out, "import RustBridge");
285 let _ = writeln!(out);
286 let _ = writeln!(out, "/// E2e tests for category: {category}.");
287 let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
288
289 if needs_chdir {
290 let _ = writeln!(out, " override class func setUp() {{");
298 let _ = writeln!(out, " super.setUp()");
299 let _ = writeln!(out, " let _testDocs = URL(fileURLWithPath: #filePath)");
300 let _ = writeln!(out, " .deletingLastPathComponent() // <Module>Tests/");
301 let _ = writeln!(out, " .deletingLastPathComponent() // Tests/");
302 let _ = writeln!(out, " .deletingLastPathComponent() // swift/");
303 let _ = writeln!(out, " .deletingLastPathComponent() // packages/");
304 let _ = writeln!(out, " .deletingLastPathComponent() // <repo root>");
305 let _ = writeln!(
306 out,
307 " .appendingPathComponent(\"{}\")",
308 e2e_config.test_documents_dir
309 );
310 let _ = writeln!(
311 out,
312 " if FileManager.default.fileExists(atPath: _testDocs.path) {{"
313 );
314 let _ = writeln!(
315 out,
316 " FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
317 );
318 let _ = writeln!(out, " }}");
319 let _ = writeln!(out, " }}");
320 let _ = writeln!(out);
321 }
322
323 for fixture in fixtures {
324 if fixture.is_http_test() {
325 render_http_test_method(&mut out, fixture);
326 } else {
327 render_test_method(
328 &mut out,
329 fixture,
330 e2e_config,
331 function_name,
332 result_var,
333 args,
334 field_resolver,
335 result_is_simple,
336 enum_fields,
337 client_factory,
338 );
339 }
340 let _ = writeln!(out);
341 }
342
343 let _ = writeln!(out, "}}");
344 out
345}
346
347struct SwiftTestClientRenderer;
354
355impl client::TestClientRenderer for SwiftTestClientRenderer {
356 fn language_name(&self) -> &'static str {
357 "swift"
358 }
359
360 fn sanitize_test_name(&self, id: &str) -> String {
361 sanitize_ident(id).to_upper_camel_case()
363 }
364
365 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
371 let _ = writeln!(out, " /// {description}");
372 let _ = writeln!(out, " func test{fn_name}() throws {{");
373 if let Some(reason) = skip_reason {
374 let escaped = escape_swift(reason);
375 let _ = writeln!(out, " try XCTSkipIf(true, \"{escaped}\")");
376 }
377 }
378
379 fn render_test_close(&self, out: &mut String) {
380 let _ = writeln!(out, " }}");
381 }
382
383 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
390 let method = ctx.method.to_uppercase();
391 let fixture_path = escape_swift(ctx.path);
392
393 let _ = writeln!(
394 out,
395 " let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
396 );
397 let _ = writeln!(
398 out,
399 " var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
400 );
401 let _ = writeln!(out, " _req.httpMethod = \"{method}\"");
402
403 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
405 header_pairs.sort_by_key(|(k, _)| k.as_str());
406 for (k, v) in &header_pairs {
407 let expanded_v = expand_fixture_templates(v);
408 let ek = escape_swift(k);
409 let ev = escape_swift(&expanded_v);
410 let _ = writeln!(out, " _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
411 }
412
413 if let Some(body) = ctx.body {
415 let json_str = serde_json::to_string(body).unwrap_or_default();
416 let escaped_body = escape_swift(&json_str);
417 let _ = writeln!(out, " _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
418 let _ = writeln!(
419 out,
420 " _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
421 );
422 }
423
424 let _ = writeln!(out, " var {}: HTTPURLResponse?", ctx.response_var);
425 let _ = writeln!(out, " var _responseData: Data?");
426 let _ = writeln!(out, " let _sema = DispatchSemaphore(value: 0)");
427 let _ = writeln!(
428 out,
429 " URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
430 );
431 let _ = writeln!(out, " {} = resp as? HTTPURLResponse", ctx.response_var);
432 let _ = writeln!(out, " _responseData = data");
433 let _ = writeln!(out, " _sema.signal()");
434 let _ = writeln!(out, " }}.resume()");
435 let _ = writeln!(out, " _sema.wait()");
436 let _ = writeln!(out, " let _resp = try XCTUnwrap({})", ctx.response_var);
437 }
438
439 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
440 let _ = writeln!(out, " XCTAssertEqual(_resp.statusCode, {status})");
441 }
442
443 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
444 let lower_name = name.to_lowercase();
445 let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
446 match expected {
447 "<<present>>" => {
448 let _ = writeln!(out, " XCTAssertNotNil({header_expr})");
449 }
450 "<<absent>>" => {
451 let _ = writeln!(out, " XCTAssertNil({header_expr})");
452 }
453 "<<uuid>>" => {
454 let _ = writeln!(out, " let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
455 let _ = writeln!(
456 out,
457 " 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))"
458 );
459 }
460 exact => {
461 let escaped = escape_swift(exact);
462 let _ = writeln!(out, " XCTAssertEqual({header_expr}, \"{escaped}\")");
463 }
464 }
465 }
466
467 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
468 if let serde_json::Value::String(s) = expected {
469 let escaped = escape_swift(s);
470 let _ = writeln!(
471 out,
472 " let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
473 );
474 let _ = writeln!(
475 out,
476 " XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
477 );
478 } else {
479 let json_str = serde_json::to_string(expected).unwrap_or_default();
480 let escaped = escape_swift(&json_str);
481 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
482 let _ = writeln!(
483 out,
484 " let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
485 );
486 let _ = writeln!(
487 out,
488 " let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
489 );
490 let _ = writeln!(
491 out,
492 " XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
493 );
494 }
495 }
496
497 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
498 if let Some(obj) = expected.as_object() {
499 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
500 let _ = writeln!(
501 out,
502 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
503 );
504 for (key, val) in obj {
505 let escaped_key = escape_swift(key);
506 let swift_val = json_to_swift(val);
507 let _ = writeln!(
508 out,
509 " XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
510 );
511 }
512 }
513 }
514
515 fn render_assert_validation_errors(
516 &self,
517 out: &mut String,
518 _response_var: &str,
519 errors: &[ValidationErrorExpectation],
520 ) {
521 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
522 let _ = writeln!(
523 out,
524 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
525 );
526 let _ = writeln!(
527 out,
528 " let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
529 );
530 for ve in errors {
531 let escaped_msg = escape_swift(&ve.msg);
532 let _ = writeln!(
533 out,
534 " XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
535 );
536 }
537 }
538}
539
540fn render_http_test_method(out: &mut String, fixture: &Fixture) {
545 let Some(http) = &fixture.http else {
546 return;
547 };
548
549 if http.expected_response.status_code == 101 {
551 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
552 let description = fixture.description.replace('"', "\\\"");
553 let _ = writeln!(out, " /// {description}");
554 let _ = writeln!(out, " func test{method_name}() throws {{");
555 let _ = writeln!(
556 out,
557 " try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
558 );
559 let _ = writeln!(out, " }}");
560 return;
561 }
562
563 client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
564}
565
566#[allow(clippy::too_many_arguments)]
571fn render_test_method(
572 out: &mut String,
573 fixture: &Fixture,
574 e2e_config: &E2eConfig,
575 _function_name: &str,
576 _result_var: &str,
577 _args: &[crate::config::ArgMapping],
578 field_resolver: &FieldResolver,
579 result_is_simple: bool,
580 enum_fields: &HashSet<String>,
581 global_client_factory: Option<&str>,
582) {
583 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
585 let lang = "swift";
586 let call_overrides = call_config.overrides.get(lang);
587 let function_name = call_overrides
588 .and_then(|o| o.function.as_ref())
589 .cloned()
590 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
591 let client_factory: Option<&str> = call_overrides
593 .and_then(|o| o.client_factory.as_deref())
594 .or(global_client_factory);
595 let result_var = &call_config.result_var;
596 let args = &call_config.args;
597 let result_is_bytes_any_lang =
604 call_config.result_is_bytes || call_config.overrides.values().any(|o| o.result_is_bytes);
605 let result_is_simple = call_config.result_is_simple
606 || call_overrides.is_some_and(|o| o.result_is_simple)
607 || result_is_simple
608 || result_is_bytes_any_lang;
609 let result_is_array = call_config.result_is_array;
610 let result_is_option = call_config.result_is_option || call_overrides.is_some_and(|o| o.result_is_option);
615
616 let method_name = fixture.id.to_upper_camel_case();
617 let description = &fixture.description;
618 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
619 let is_async = call_config.r#async;
620
621 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
623 let collect_snippet_opt = if is_streaming && !expects_error {
624 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(lang, result_var, "chunks")
625 } else {
626 None
627 };
628 if is_streaming && !expects_error && collect_snippet_opt.is_none() {
635 if is_async {
636 let _ = writeln!(out, " func test{method_name}() async throws {{");
637 } else {
638 let _ = writeln!(out, " func test{method_name}() throws {{");
639 }
640 let _ = writeln!(out, " // {description}");
641 let _ = writeln!(
642 out,
643 " try XCTSkipIf(true, \"swift: streaming chunk collection is not yet supported via the swift-bridge surface (fixture: {})\")",
644 fixture.id
645 );
646 let _ = writeln!(out, " }}");
647 return;
648 }
649 let collect_snippet = collect_snippet_opt.unwrap_or_default();
650
651 let has_unresolvable_json_object_arg = {
658 let options_via = call_overrides.and_then(|o| o.options_via.as_deref());
659 options_via.is_none() && args.iter().any(|a| a.arg_type == "json_object" && a.name != "config")
660 };
661
662 if has_unresolvable_json_object_arg {
663 if is_async {
664 let _ = writeln!(out, " func test{method_name}() async throws {{");
665 } else {
666 let _ = writeln!(out, " func test{method_name}() throws {{");
667 }
668 let _ = writeln!(out, " // {description}");
669 let _ = writeln!(
670 out,
671 " try XCTSkipIf(true, \"swift: json_object request construction requires options_via configuration (fixture: {})\");",
672 fixture.id
673 );
674 let _ = writeln!(out, " }}");
675 return;
676 }
677
678 let mut visitor_setup_lines: Vec<String> = Vec::new();
682 let visitor_handle_expr: Option<String> = fixture
683 .visitor
684 .as_ref()
685 .map(|spec| super::swift_visitors::build_swift_visitor(&mut visitor_setup_lines, spec, &fixture.id));
686
687 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
691
692 let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
697 let per_call = call_overrides.map(|o| &o.enum_fields);
698 if let Some(pc) = per_call {
699 if !pc.is_empty() {
700 let mut merged = enum_fields.clone();
701 merged.extend(pc.keys().cloned());
702 std::borrow::Cow::Owned(merged)
703 } else {
704 std::borrow::Cow::Borrowed(enum_fields)
705 }
706 } else {
707 std::borrow::Cow::Borrowed(enum_fields)
708 }
709 };
710
711 let options_via_str: Option<&str> = call_overrides.and_then(|o| o.options_via.as_deref());
712 let options_type_str: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
713 let handle_config_fn_owned: Option<String> = call_config
717 .overrides
718 .get("c")
719 .and_then(|c| c.c_engine_factory.as_deref())
720 .map(|ty| format!("{}_from_json", ty.to_snake_case()).to_lower_camel_case());
721 let (mut setup_lines, args_str) = build_args_and_setup(
722 &fixture.input,
723 args,
724 &fixture.id,
725 fixture.has_host_root_route(),
726 &function_name,
727 options_via_str,
728 options_type_str,
729 handle_config_fn_owned.as_deref(),
730 visitor_handle_expr.as_deref(),
731 );
732 if !visitor_setup_lines.is_empty() {
734 visitor_setup_lines.extend(setup_lines);
735 setup_lines = visitor_setup_lines;
736 }
737
738 let args_str = if extra_args.is_empty() {
740 args_str
741 } else if args_str.is_empty() {
742 extra_args.join(", ")
743 } else {
744 format!("{args_str}, {}", extra_args.join(", "))
745 };
746
747 let has_mock = fixture.mock_response.is_some();
752 let (call_setup, call_expr) = if let Some(_factory) = client_factory {
753 let env_key = format!("MOCK_SERVER_{}", fixture.id.to_ascii_uppercase().replace('-', "_"));
754 let mock_url = if fixture.has_host_root_route() {
755 format!(
756 "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\")",
757 fixture.id
758 )
759 } else {
760 format!(
761 "ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\"",
762 fixture.id
763 )
764 };
765 let client_constructor = if has_mock {
766 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
767 } else {
768 if let Some(env_var) = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()) {
770 format!(
771 "let _apiKey = ProcessInfo.processInfo.environment[\"{env_var}\"]\n \
772 let _baseUrl: String? = _apiKey != nil ? nil : {mock_url}\n \
773 let _client = try DefaultClient(apiKey: _apiKey ?? \"test-key\", baseUrl: _baseUrl)"
774 )
775 } else {
776 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
777 }
778 };
779 let expr = if is_async {
780 format!("try await _client.{function_name}({args_str})")
781 } else {
782 format!("try _client.{function_name}({args_str})")
783 };
784 (Some(client_constructor), expr)
785 } else {
786 let expr = if is_async {
788 format!("try await {function_name}({args_str})")
789 } else {
790 format!("try {function_name}({args_str})")
791 };
792 (None, expr)
793 };
794 let _ = function_name;
796
797 if is_async {
798 let _ = writeln!(out, " func test{method_name}() async throws {{");
799 } else {
800 let _ = writeln!(out, " func test{method_name}() throws {{");
801 }
802 let _ = writeln!(out, " // {description}");
803
804 if expects_error {
805 if is_async {
809 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 } else {
826 let _ = writeln!(out, " do {{");
833 for line in &setup_lines {
834 let _ = writeln!(out, " {line}");
835 }
836 if let Some(setup) = &call_setup {
837 let _ = writeln!(out, " {setup}");
838 }
839 let _ = writeln!(out, " _ = {call_expr}");
840 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
841 let _ = writeln!(out, " }} catch {{");
842 let _ = writeln!(out, " // success");
843 let _ = writeln!(out, " }}");
844 }
845 let _ = writeln!(out, " }}");
846 return;
847 }
848
849 for line in &setup_lines {
850 let _ = writeln!(out, " {line}");
851 }
852
853 if let Some(setup) = &call_setup {
855 let _ = writeln!(out, " {setup}");
856 }
857
858 let _ = writeln!(out, " let {result_var} = {call_expr}");
859
860 if !collect_snippet.is_empty() {
863 for line in collect_snippet.lines() {
864 let _ = writeln!(out, " {line}");
865 }
866 }
867
868 let fixture_root_type: Option<String> = swift_call_result_type(call_config);
873 let fixture_resolver = field_resolver.with_swift_root_type(fixture_root_type);
874
875 for assertion in &fixture.assertions {
876 render_assertion(
877 out,
878 assertion,
879 result_var,
880 &fixture_resolver,
881 result_is_simple,
882 result_is_array,
883 result_is_option,
884 &effective_enum_fields,
885 is_streaming,
886 );
887 }
888
889 let _ = writeln!(out, " }}");
890}
891
892#[allow(clippy::too_many_arguments)]
893fn build_args_and_setup(
907 input: &serde_json::Value,
908 args: &[crate::config::ArgMapping],
909 fixture_id: &str,
910 has_host_root_route: bool,
911 function_name: &str,
912 options_via: Option<&str>,
913 options_type: Option<&str>,
914 handle_config_fn: Option<&str>,
915 visitor_handle_expr: Option<&str>,
916) -> (Vec<String>, String) {
917 if args.is_empty() {
918 return (Vec::new(), String::new());
919 }
920
921 let mut setup_lines: Vec<String> = Vec::new();
922 let mut parts: Vec<String> = Vec::new();
923
924 let later_emits: Vec<bool> = (0..args.len())
929 .map(|i| {
930 args.iter().skip(i + 1).any(|a| {
931 let f = a.field.strip_prefix("input.").unwrap_or(&a.field);
932 let v = input.get(f);
933 let has_value = matches!(v, Some(x) if !x.is_null());
934 has_value || !a.optional || (a.arg_type == "json_object" && a.name == "config")
935 })
936 })
937 .collect();
938
939 for (idx, arg) in args.iter().enumerate() {
940 if arg.arg_type == "mock_url" {
941 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_ascii_uppercase().replace('-', "_"));
942 let url_expr = if has_host_root_route {
943 format!(
944 "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\")"
945 )
946 } else {
947 format!("ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"")
948 };
949 setup_lines.push(format!("let {} = {url_expr}", arg.name));
950 parts.push(arg.name.clone());
951 continue;
952 }
953
954 if arg.arg_type == "handle" {
955 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
956 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
957 let config_val = input.get(field);
958 let has_config = config_val
959 .is_some_and(|v| !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty())));
960 if has_config {
961 if let Some(from_json_fn) = handle_config_fn {
962 let json_str = serde_json::to_string(config_val.unwrap()).unwrap_or_default();
963 let escaped = escape_swift_str(&json_str);
964 let config_var = format!("{}Config", arg.name.to_lower_camel_case());
965 setup_lines.push(format!("let {config_var} = try {from_json_fn}(\"{escaped}\")"));
966 setup_lines.push(format!("let {var_name} = try createEngine({config_var})"));
967 } else {
968 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
969 }
970 } else {
971 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
972 }
973 parts.push(var_name);
974 continue;
975 }
976
977 if arg.arg_type == "bytes" {
982 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
983 let val = input.get(field);
984 match val {
985 None | Some(serde_json::Value::Null) if arg.optional => {
986 if later_emits[idx] {
987 parts.push("nil".to_string());
988 }
989 }
990 None | Some(serde_json::Value::Null) => {
991 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
992 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
993 parts.push(var_name);
994 }
995 Some(serde_json::Value::String(s)) => {
996 let escaped = escape_swift(s);
997 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
998 let data_var = format!("{}Data", arg.name.to_lower_camel_case());
999 setup_lines.push(format!(
1000 "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
1001 ));
1002 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1003 setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
1004 parts.push(var_name);
1005 }
1006 Some(serde_json::Value::Array(arr)) => {
1007 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1008 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1009 for v in arr {
1010 if let Some(n) = v.as_u64() {
1011 setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
1012 }
1013 }
1014 parts.push(var_name);
1015 }
1016 Some(other) => {
1017 let json_str = serde_json::to_string(other).unwrap_or_default();
1019 let escaped = escape_swift(&json_str);
1020 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1021 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1022 setup_lines.push(format!(
1023 "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
1024 ));
1025 parts.push(var_name);
1026 }
1027 }
1028 continue;
1029 }
1030
1031 let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
1037 let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
1038 if is_config_arg && !is_batch_fn {
1039 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1040 let val = input.get(field);
1041 let json_str = match val {
1042 None | Some(serde_json::Value::Null) => "{}".to_string(),
1043 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1044 };
1045 let escaped = escape_swift(&json_str);
1046 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
1047 let from_json_fn = if let Some(type_name) = options_type {
1049 format!("{}FromJson", type_name.to_lower_camel_case())
1050 } else {
1051 "extractionConfigFromJson".to_string()
1052 };
1053 setup_lines.push(format!("let {var_name} = try {from_json_fn}(\"{escaped}\")"));
1054 parts.push(var_name);
1055 continue;
1056 }
1057
1058 if arg.arg_type == "json_object" && options_via == Some("from_json") {
1065 if let Some(type_name) = options_type {
1066 let resolved_val = super::resolve_field(input, &arg.field);
1067 let json_str = match resolved_val {
1068 serde_json::Value::Null => "{}".to_string(),
1069 v => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1070 };
1071 let escaped = escape_swift(&json_str);
1072 let var_name = format!("_{}", arg.name.to_lower_camel_case());
1073 if let Some(handle_expr) = visitor_handle_expr {
1074 let with_visitor_fn = format!("{}FromJsonWithVisitor", type_name.to_lower_camel_case());
1079 let handle_var = format!("_visitorHandle_{}", var_name.trim_start_matches('_'));
1080 setup_lines.push(format!("let {handle_var} = {handle_expr}"));
1081 setup_lines.push(format!(
1082 "let {var_name} = try {with_visitor_fn}(\"{escaped}\", {handle_var})"
1083 ));
1084 } else {
1085 let from_json_fn = format!("{}FromJson", type_name.to_lower_camel_case());
1086 setup_lines.push(format!("let {var_name} = try {from_json_fn}(\"{escaped}\")"));
1087 }
1088 parts.push(var_name);
1089 continue;
1090 }
1091 }
1092
1093 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1094 let val = input.get(field);
1095 match val {
1096 None | Some(serde_json::Value::Null) if arg.optional => {
1097 if later_emits[idx] {
1101 parts.push("nil".to_string());
1102 }
1103 }
1104 None | Some(serde_json::Value::Null) => {
1105 let default_val = match arg.arg_type.as_str() {
1106 "string" => "\"\"".to_string(),
1107 "int" | "integer" => "0".to_string(),
1108 "float" | "number" => "0.0".to_string(),
1109 "bool" | "boolean" => "false".to_string(),
1110 _ => "nil".to_string(),
1111 };
1112 parts.push(default_val);
1113 }
1114 Some(v) => {
1115 parts.push(json_to_swift(v));
1116 }
1117 }
1118 }
1119
1120 (setup_lines, parts.join(", "))
1121}
1122
1123#[allow(clippy::too_many_arguments)]
1124fn render_assertion(
1125 out: &mut String,
1126 assertion: &Assertion,
1127 result_var: &str,
1128 field_resolver: &FieldResolver,
1129 result_is_simple: bool,
1130 result_is_array: bool,
1131 result_is_option: bool,
1132 enum_fields: &HashSet<String>,
1133 is_streaming: bool,
1134) {
1135 let bare_result_is_option = result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1140 if let Some(f) = &assertion.field {
1145 let is_streaming_usage_path =
1146 is_streaming && (f == "usage" || (f.starts_with("usage.") || f.starts_with("usage[")));
1147 if !f.is_empty()
1148 && (crate::codegen::streaming_assertions::is_streaming_virtual_field(f) || is_streaming_usage_path)
1149 {
1150 if let Some(expr) =
1151 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "swift", "chunks")
1152 {
1153 let line = match assertion.assertion_type.as_str() {
1154 "count_min" => {
1155 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1156 format!(" XCTAssertGreaterThanOrEqual(chunks.count, {n})\n")
1157 } else {
1158 String::new()
1159 }
1160 }
1161 "count_equals" => {
1162 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1163 format!(" XCTAssertEqual(chunks.count, {n})\n")
1164 } else {
1165 String::new()
1166 }
1167 }
1168 "equals" => {
1169 if let Some(serde_json::Value::String(s)) = &assertion.value {
1170 let escaped = escape_swift(s);
1171 format!(" XCTAssertEqual({expr}, \"{escaped}\")\n")
1172 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1173 format!(" XCTAssertEqual({expr}, {b})\n")
1174 } else {
1175 String::new()
1176 }
1177 }
1178 "not_empty" => {
1179 format!(" XCTAssertFalse({expr}.isEmpty, \"expected non-empty\")\n")
1180 }
1181 "is_empty" => {
1182 format!(" XCTAssertTrue({expr}.isEmpty, \"expected empty\")\n")
1183 }
1184 "is_true" => {
1185 format!(" XCTAssertTrue({expr})\n")
1186 }
1187 "is_false" => {
1188 format!(" XCTAssertFalse({expr})\n")
1189 }
1190 "greater_than" => {
1191 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1192 format!(" XCTAssertGreaterThan(chunks.count, {n})\n")
1193 } else {
1194 String::new()
1195 }
1196 }
1197 "contains" => {
1198 if let Some(serde_json::Value::String(s)) = &assertion.value {
1199 let escaped = escape_swift(s);
1200 format!(
1201 " XCTAssertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1202 )
1203 } else {
1204 String::new()
1205 }
1206 }
1207 _ => format!(
1208 " // streaming field '{f}': assertion type '{}' not rendered\n",
1209 assertion.assertion_type
1210 ),
1211 };
1212 if !line.is_empty() {
1213 out.push_str(&line);
1214 }
1215 }
1216 return;
1217 }
1218 }
1219
1220 if let Some(f) = &assertion.field {
1222 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1223 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1224 return;
1225 }
1226 }
1227
1228 if let Some(f) = &assertion.field {
1233 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1234 let _ = writeln!(
1235 out,
1236 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
1237 );
1238 return;
1239 }
1240 }
1241
1242 let field_is_enum = assertion
1244 .field
1245 .as_deref()
1246 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1247
1248 let field_is_optional = assertion.field.as_deref().is_some_and(|f| {
1249 !f.is_empty() && (field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f)))
1250 });
1251 let field_is_array = assertion.field.as_deref().is_some_and(|f| {
1252 !f.is_empty()
1253 && (field_resolver.is_array(f)
1254 || field_resolver.is_array(field_resolver.resolve(f))
1255 || field_resolver.is_collection_root(f)
1256 || field_resolver.is_collection_root(field_resolver.resolve(f)))
1257 });
1258
1259 let field_expr_raw = if result_is_simple {
1260 result_var.to_string()
1261 } else {
1262 match &assertion.field {
1263 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
1264 _ => result_var.to_string(),
1265 }
1266 };
1267
1268 let local_suffix = {
1278 use std::hash::{Hash, Hasher};
1279 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1280 assertion.field.hash(&mut hasher);
1281 assertion
1282 .value
1283 .as_ref()
1284 .map(|v| v.to_string())
1285 .unwrap_or_default()
1286 .hash(&mut hasher);
1287 format!(
1288 "{}_{:x}",
1289 assertion.assertion_type.replace(['-', '.'], "_"),
1290 hasher.finish() & 0xffff_ffff,
1291 )
1292 };
1293 let (vec_setup, field_expr, is_map_subscript) = materialise_vec_temporaries(&field_expr_raw, &local_suffix);
1294 let field_uses_traversal = assertion.field.as_deref().is_some_and(|f| f.contains("[]."));
1299 let traversal_skips_field_expr = field_uses_traversal
1300 && matches!(
1301 assertion.assertion_type.as_str(),
1302 "contains" | "not_contains" | "not_empty" | "is_empty"
1303 );
1304 if !traversal_skips_field_expr {
1305 for line in &vec_setup {
1306 let _ = writeln!(out, " {line}");
1307 }
1308 }
1309
1310 let accessor_is_optional = field_expr.contains("?.");
1316
1317 let string_expr = if is_map_subscript {
1326 format!("({field_expr} ?? \"\")")
1330 } else if field_is_enum && (field_is_optional || accessor_is_optional) {
1331 format!("({field_expr}?.toString() ?? \"\")")
1334 } else if field_is_enum {
1335 format!("{field_expr}.toString()")
1340 } else if field_is_optional {
1341 format!("({field_expr}?.toString() ?? \"\")")
1343 } else if accessor_is_optional {
1344 format!("({field_expr}.toString() ?? \"\")")
1347 } else {
1348 format!("{field_expr}.toString()")
1349 };
1350
1351 match assertion.assertion_type.as_str() {
1352 "equals" => {
1353 if let Some(expected) = &assertion.value {
1354 let swift_val = json_to_swift(expected);
1355 if expected.is_string() {
1356 if field_is_enum {
1357 let trim_expr =
1361 format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)");
1362 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1363 } else {
1364 let trim_expr =
1369 format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)");
1370 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1371 }
1372 } else {
1373 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
1374 }
1375 }
1376 }
1377 "contains" => {
1378 if let Some(expected) = &assertion.value {
1379 let swift_val = json_to_swift(expected);
1380 let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
1383 if result_is_simple && result_is_array && no_field {
1384 let _ = writeln!(
1387 out,
1388 " XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1389 );
1390 } else {
1391 let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1393 if let Some(dot) = f.find("[].") {
1394 let array_part = &f[..dot];
1395 let elem_part = &f[dot + 3..];
1396 let line = swift_traversal_contains_assert(
1397 array_part,
1398 elem_part,
1399 f,
1400 &swift_val,
1401 result_var,
1402 false,
1403 &format!("expected to contain: \\({swift_val})"),
1404 enum_fields,
1405 field_resolver,
1406 );
1407 let _ = writeln!(out, "{line}");
1408 true
1409 } else {
1410 false
1411 }
1412 } else {
1413 false
1414 };
1415 if !traversal_handled {
1416 let field_is_array = assertion
1418 .field
1419 .as_deref()
1420 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1421 if field_is_array {
1422 let contains_expr =
1423 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1424 let _ = writeln!(
1425 out,
1426 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1427 );
1428 } else if field_is_enum {
1429 let _ = writeln!(
1432 out,
1433 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1434 );
1435 } else {
1436 let _ = writeln!(
1437 out,
1438 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1439 );
1440 }
1441 }
1442 }
1443 }
1444 }
1445 "contains_all" => {
1446 if let Some(values) = &assertion.values {
1447 if let Some(f) = assertion.field.as_deref() {
1449 if let Some(dot) = f.find("[].") {
1450 let array_part = &f[..dot];
1451 let elem_part = &f[dot + 3..];
1452 for val in values {
1453 let swift_val = json_to_swift(val);
1454 let line = swift_traversal_contains_assert(
1455 array_part,
1456 elem_part,
1457 f,
1458 &swift_val,
1459 result_var,
1460 false,
1461 &format!("expected to contain: \\({swift_val})"),
1462 enum_fields,
1463 field_resolver,
1464 );
1465 let _ = writeln!(out, "{line}");
1466 }
1467 } else {
1469 let field_is_array = field_resolver.is_array(field_resolver.resolve(f));
1471 if field_is_array {
1472 let contains_expr =
1473 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1474 for val in values {
1475 let swift_val = json_to_swift(val);
1476 let _ = writeln!(
1477 out,
1478 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1479 );
1480 }
1481 } else if field_is_enum {
1482 for val in values {
1485 let swift_val = json_to_swift(val);
1486 let _ = writeln!(
1487 out,
1488 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1489 );
1490 }
1491 } else {
1492 for val in values {
1493 let swift_val = json_to_swift(val);
1494 let _ = writeln!(
1495 out,
1496 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1497 );
1498 }
1499 }
1500 }
1501 } else {
1502 for val in values {
1504 let swift_val = json_to_swift(val);
1505 let _ = writeln!(
1506 out,
1507 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1508 );
1509 }
1510 }
1511 }
1512 }
1513 "not_contains" => {
1514 if let Some(expected) = &assertion.value {
1515 let swift_val = json_to_swift(expected);
1516 let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1518 if let Some(dot) = f.find("[].") {
1519 let array_part = &f[..dot];
1520 let elem_part = &f[dot + 3..];
1521 let line = swift_traversal_contains_assert(
1522 array_part,
1523 elem_part,
1524 f,
1525 &swift_val,
1526 result_var,
1527 true,
1528 &format!("expected NOT to contain: \\({swift_val})"),
1529 enum_fields,
1530 field_resolver,
1531 );
1532 let _ = writeln!(out, "{line}");
1533 true
1534 } else {
1535 false
1536 }
1537 } else {
1538 false
1539 };
1540 if !traversal_handled {
1541 let _ = writeln!(
1542 out,
1543 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
1544 );
1545 }
1546 }
1547 }
1548 "not_empty" => {
1549 let traversal_not_empty_handled = if let Some(f) = assertion.field.as_deref() {
1556 if let Some(dot) = f.find("[].") {
1557 let array_part = &f[..dot];
1558 let elem_part = &f[dot + 3..];
1559 let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1560 let resolved_full = field_resolver.resolve(f);
1561 let resolved_elem_part = resolved_full
1562 .find("[].")
1563 .map(|d| &resolved_full[d + 3..])
1564 .unwrap_or(elem_part);
1565 let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1566 let elem_is_enum = enum_fields.contains(f) || enum_fields.contains(resolved_full);
1567 let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1568 || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1569 let elem_str = if elem_is_enum {
1570 format!("{elem_accessor}.to_string().toString()")
1571 } else if elem_is_optional {
1572 format!("({elem_accessor}?.toString() ?? \"\")")
1573 } else {
1574 format!("{elem_accessor}.toString()")
1575 };
1576 let _ = writeln!(
1577 out,
1578 " XCTAssertTrue({array_accessor}.contains(where: {{ !{elem_str}.isEmpty }}), \"expected non-empty value\")"
1579 );
1580 true
1581 } else {
1582 false
1583 }
1584 } else {
1585 false
1586 };
1587 if !traversal_not_empty_handled {
1588 if bare_result_is_option {
1589 let _ = writeln!(out, " XCTAssertNotNil({result_var}, \"expected non-nil value\")");
1590 } else if field_is_optional {
1591 let _ = writeln!(out, " XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
1592 } else if field_is_array {
1593 let _ = writeln!(
1594 out,
1595 " XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
1596 );
1597 } else if result_is_simple {
1598 let _ = writeln!(
1600 out,
1601 " XCTAssertFalse({result_var}.isEmpty, \"expected non-empty value\")"
1602 );
1603 } else {
1604 let len_expr = if accessor_is_optional {
1613 format!("({field_expr}.count ?? 0)")
1614 } else {
1615 format!("{field_expr}.count")
1616 };
1617 let _ = writeln!(
1618 out,
1619 " XCTAssertGreaterThan({len_expr}, 0, \"expected non-empty value\")"
1620 );
1621 }
1622 }
1623 }
1624 "is_empty" => {
1625 if bare_result_is_option {
1626 let _ = writeln!(out, " XCTAssertNil({result_var}, \"expected nil value\")");
1627 } else if field_is_optional {
1628 let _ = writeln!(out, " XCTAssertNil({field_expr}, \"expected nil value\")");
1629 } else if field_is_array {
1630 let _ = writeln!(
1631 out,
1632 " XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
1633 );
1634 } else {
1635 let len_expr = if accessor_is_optional {
1637 format!("({field_expr}.count ?? 0)")
1638 } else {
1639 format!("{field_expr}.count")
1640 };
1641 let _ = writeln!(out, " XCTAssertEqual({len_expr}, 0, \"expected empty value\")");
1642 }
1643 }
1644 "contains_any" => {
1645 if let Some(values) = &assertion.values {
1646 let checks: Vec<String> = values
1647 .iter()
1648 .map(|v| {
1649 let swift_val = json_to_swift(v);
1650 format!("{string_expr}.contains({swift_val})")
1651 })
1652 .collect();
1653 let joined = checks.join(" || ");
1654 let _ = writeln!(
1655 out,
1656 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
1657 );
1658 }
1659 }
1660 "greater_than" => {
1661 if let Some(val) = &assertion.value {
1662 let swift_val = json_to_swift(val);
1663 let field_is_optional = accessor_is_optional
1666 || assertion.field.as_deref().is_some_and(|f| {
1667 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1668 });
1669 let compare_expr = if field_is_optional {
1670 format!("({field_expr} ?? 0)")
1671 } else {
1672 field_expr.clone()
1673 };
1674 let _ = writeln!(out, " XCTAssertGreaterThan({compare_expr}, {swift_val})");
1675 }
1676 }
1677 "less_than" => {
1678 if let Some(val) = &assertion.value {
1679 let swift_val = json_to_swift(val);
1680 let field_is_optional = accessor_is_optional
1681 || assertion.field.as_deref().is_some_and(|f| {
1682 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1683 });
1684 let compare_expr = if field_is_optional {
1685 format!("({field_expr} ?? 0)")
1686 } else {
1687 field_expr.clone()
1688 };
1689 let _ = writeln!(out, " XCTAssertLessThan({compare_expr}, {swift_val})");
1690 }
1691 }
1692 "greater_than_or_equal" => {
1693 if let Some(val) = &assertion.value {
1694 let swift_val = json_to_swift(val);
1695 let field_is_optional = accessor_is_optional
1698 || assertion.field.as_deref().is_some_and(|f| {
1699 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1700 });
1701 let compare_expr = if field_is_optional {
1702 format!("({field_expr} ?? 0)")
1703 } else {
1704 field_expr.clone()
1705 };
1706 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
1707 }
1708 }
1709 "less_than_or_equal" => {
1710 if let Some(val) = &assertion.value {
1711 let swift_val = json_to_swift(val);
1712 let field_is_optional = accessor_is_optional
1713 || assertion.field.as_deref().is_some_and(|f| {
1714 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1715 });
1716 let compare_expr = if field_is_optional {
1717 format!("({field_expr} ?? 0)")
1718 } else {
1719 field_expr.clone()
1720 };
1721 let _ = writeln!(out, " XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
1722 }
1723 }
1724 "starts_with" => {
1725 if let Some(expected) = &assertion.value {
1726 let swift_val = json_to_swift(expected);
1727 let _ = writeln!(
1728 out,
1729 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1730 );
1731 }
1732 }
1733 "ends_with" => {
1734 if let Some(expected) = &assertion.value {
1735 let swift_val = json_to_swift(expected);
1736 let _ = writeln!(
1737 out,
1738 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1739 );
1740 }
1741 }
1742 "min_length" => {
1743 if let Some(val) = &assertion.value {
1744 if let Some(n) = val.as_u64() {
1745 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1748 }
1749 }
1750 }
1751 "max_length" => {
1752 if let Some(val) = &assertion.value {
1753 if let Some(n) = val.as_u64() {
1754 let _ = writeln!(out, " XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1755 }
1756 }
1757 }
1758 "count_min" => {
1759 if let Some(val) = &assertion.value {
1760 if let Some(n) = val.as_u64() {
1761 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1765 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1766 }
1767 }
1768 }
1769 "count_equals" => {
1770 if let Some(val) = &assertion.value {
1771 if let Some(n) = val.as_u64() {
1772 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1773 let _ = writeln!(out, " XCTAssertEqual({count_expr}, {n})");
1774 }
1775 }
1776 }
1777 "is_true" => {
1778 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
1779 }
1780 "is_false" => {
1781 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
1782 }
1783 "matches_regex" => {
1784 if let Some(expected) = &assertion.value {
1785 let swift_val = json_to_swift(expected);
1786 let _ = writeln!(
1787 out,
1788 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1789 );
1790 }
1791 }
1792 "not_error" => {
1793 }
1795 "error" => {
1796 }
1798 "method_result" => {
1799 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
1800 }
1801 other => {
1802 panic!("Swift e2e generator: unsupported assertion type: {other}");
1803 }
1804 }
1805}
1806
1807fn materialise_vec_temporaries(expr: &str, name_suffix: &str) -> (Vec<String>, String, bool) {
1832 let Some(idx) = expr.find("()[") else {
1833 return (Vec::new(), expr.to_string(), false);
1834 };
1835 let after_open = idx + 3; let Some(close_rel) = expr[after_open..].find(']') else {
1837 return (Vec::new(), expr.to_string(), false);
1838 };
1839 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);
1844 let method = &expr[method_dot + 1..idx];
1845 let local = format!("_vec_{}_{}", method, name_suffix);
1846
1847 let inner = subscript.trim_start_matches('[').trim_end_matches(']');
1852 let is_string_key = inner.starts_with('"') && inner.ends_with('"');
1853 let setup = if is_string_key {
1854 format!(
1855 "let {local} = (try? JSONSerialization.jsonObject(with: ({prefix}.toString() ?? \"{{}}\").data(using: .utf8)!) as? [String: String]) ?? [:]"
1856 )
1857 } else {
1858 format!("let {local} = {prefix}")
1859 };
1860
1861 let rewritten = format!("{local}{subscript}{tail}");
1862 (vec![setup], rewritten, is_string_key)
1863}
1864
1865fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1868 let resolved = field_resolver.resolve(field);
1869 let parts: Vec<&str> = resolved.split('.').collect();
1870
1871 let mut out = result_var.to_string();
1874 let mut has_optional = false;
1875 let mut path_so_far = String::new();
1876 let total = parts.len();
1877 for (i, part) in parts.iter().enumerate() {
1878 let is_leaf = i == total - 1;
1879 let (field_name, subscript): (&str, Option<&str>) = if let Some(bracket_pos) = part.find('[') {
1883 (&part[..bracket_pos], Some(&part[bracket_pos..]))
1884 } else {
1885 (part, None)
1886 };
1887
1888 if !path_so_far.is_empty() {
1889 path_so_far.push('.');
1890 }
1891 let base_path = {
1895 let mut p = path_so_far.clone();
1896 p.push_str(field_name);
1897 p
1898 };
1899 path_so_far.push_str(part);
1902
1903 out.push('.');
1904 out.push_str(field_name);
1905 if let Some(sub) = subscript {
1906 let field_is_optional = field_resolver.is_optional(&base_path);
1910 if field_is_optional {
1911 out.push_str("()?");
1912 has_optional = true;
1913 } else {
1914 out.push_str("()");
1915 }
1916 out.push_str(sub);
1917 } else {
1927 out.push_str("()");
1928 if !is_leaf && field_resolver.is_optional(&base_path) {
1931 out.push('?');
1932 has_optional = true;
1933 }
1934 }
1935 }
1936 (out, has_optional)
1937}
1938
1939#[allow(clippy::too_many_arguments)]
1961fn swift_traversal_contains_assert(
1962 array_part: &str,
1963 element_part: &str,
1964 full_field: &str,
1965 val_expr: &str,
1966 result_var: &str,
1967 negate: bool,
1968 msg: &str,
1969 enum_fields: &std::collections::HashSet<String>,
1970 field_resolver: &FieldResolver,
1971) -> String {
1972 let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1973 let resolved_full = field_resolver.resolve(full_field);
1974 let resolved_elem_part = resolved_full
1975 .find("[].")
1976 .map(|d| &resolved_full[d + 3..])
1977 .unwrap_or(element_part);
1978 let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1979 let elem_is_enum = enum_fields.contains(full_field) || enum_fields.contains(resolved_full);
1980 let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1981 || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1982 let elem_str = if elem_is_enum {
1983 format!("{elem_accessor}.toString()")
1986 } else if elem_is_optional {
1987 format!("({elem_accessor}?.toString() ?? \"\")")
1988 } else {
1989 format!("{elem_accessor}.toString()")
1990 };
1991 let assert_fn = if negate { "XCTAssertFalse" } else { "XCTAssertTrue" };
1992 format!(" {assert_fn}({array_accessor}.contains(where: {{ {elem_str}.contains({val_expr}) }}), \"{msg}\")")
1993}
1994
1995fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1996 let Some(f) = field else {
1997 return format!("{result_var}.map {{ $0.as_str().toString() }}");
1998 };
1999 let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
2000 format!("{accessor}?.map {{ $0.as_str().toString() }}")
2003}
2004
2005fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
2014 let Some(f) = field else {
2015 return format!("{result_var}.count");
2016 };
2017 let (accessor, mut has_optional) = swift_build_accessor(f, result_var, field_resolver);
2018 if field_resolver.is_optional(f) {
2020 has_optional = true;
2021 }
2022 if has_optional {
2023 if accessor.contains("?.") {
2026 format!("{accessor}.count ?? 0")
2027 } else {
2028 format!("({accessor}?.count ?? 0)")
2031 }
2032 } else {
2033 format!("{accessor}.count")
2034 }
2035}
2036
2037fn json_to_swift(value: &serde_json::Value) -> String {
2039 match value {
2040 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
2041 serde_json::Value::Bool(b) => b.to_string(),
2042 serde_json::Value::Number(n) => n.to_string(),
2043 serde_json::Value::Null => "nil".to_string(),
2044 serde_json::Value::Array(arr) => {
2045 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
2046 format!("[{}]", items.join(", "))
2047 }
2048 serde_json::Value::Object(_) => {
2049 let json_str = serde_json::to_string(value).unwrap_or_default();
2050 format!("\"{}\"", escape_swift(&json_str))
2051 }
2052 }
2053}
2054
2055fn escape_swift(s: &str) -> String {
2057 escape_swift_str(s)
2058}
2059
2060fn swift_call_result_type(call_config: &alef_core::config::e2e::CallConfig) -> Option<String> {
2074 const LOOKUP_LANGS: &[&str] = &["c", "csharp", "java", "kotlin", "go", "php"];
2075 for lang in LOOKUP_LANGS {
2076 if let Some(o) = call_config.overrides.get(*lang)
2077 && let Some(rt) = o.result_type.as_deref()
2078 && !rt.is_empty()
2079 {
2080 return Some(rt.to_string());
2081 }
2082 }
2083 None
2084}
2085
2086fn swift_first_class_field_supported(ty: &alef_core::ir::TypeRef) -> bool {
2091 use alef_core::ir::TypeRef;
2092 match ty {
2093 TypeRef::Primitive(_) | TypeRef::String => true,
2094 TypeRef::Optional(inner) => swift_first_class_field_supported(inner),
2095 _ => false,
2096 }
2097}
2098
2099fn build_swift_first_class_map(
2112 type_defs: &[alef_core::ir::TypeDef],
2113 e2e_config: &crate::config::E2eConfig,
2114) -> SwiftFirstClassMap {
2115 use alef_core::ir::TypeRef;
2116 let mut first_class_types: HashSet<String> = HashSet::new();
2117 let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2118 fn inner_named(ty: &TypeRef) -> Option<String> {
2119 match ty {
2120 TypeRef::Named(n) => Some(n.clone()),
2121 TypeRef::Optional(inner) | TypeRef::Vec(inner) => inner_named(inner),
2122 _ => None,
2123 }
2124 }
2125 for td in type_defs {
2126 let is_first_class = !td.is_opaque
2127 && td.has_serde
2128 && !td.fields.is_empty()
2129 && td.fields.iter().all(|f| swift_first_class_field_supported(&f.ty));
2130 if is_first_class {
2131 first_class_types.insert(td.name.clone());
2132 }
2133 let mut td_field_types: HashMap<String, String> = HashMap::new();
2134 for f in &td.fields {
2135 if let Some(named) = inner_named(&f.ty) {
2136 td_field_types.insert(f.name.clone(), named);
2137 }
2138 }
2139 if !td_field_types.is_empty() {
2140 field_types.insert(td.name.clone(), td_field_types);
2141 }
2142 }
2143 let root_type = if e2e_config.result_fields.is_empty() {
2147 None
2148 } else {
2149 let matches: Vec<&alef_core::ir::TypeDef> = type_defs
2150 .iter()
2151 .filter(|td| {
2152 let names: HashSet<&str> = td.fields.iter().map(|f| f.name.as_str()).collect();
2153 e2e_config.result_fields.iter().all(|rf| names.contains(rf.as_str()))
2154 })
2155 .collect();
2156 if matches.len() == 1 {
2157 Some(matches[0].name.clone())
2158 } else {
2159 None
2160 }
2161 };
2162 SwiftFirstClassMap {
2163 first_class_types,
2164 field_types,
2165 root_type,
2166 }
2167}
2168
2169#[cfg(test)]
2170mod tests {
2171 use super::*;
2172 use crate::field_access::FieldResolver;
2173 use std::collections::{HashMap, HashSet};
2174
2175 fn make_resolver_tool_calls() -> FieldResolver {
2176 let mut optional = HashSet::new();
2180 optional.insert("choices.message.tool_calls".to_string());
2181 let mut arrays = HashSet::new();
2182 arrays.insert("choices".to_string());
2183 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
2184 }
2185
2186 #[test]
2193 fn optional_vec_subscript_does_not_emit_trailing_question_mark_before_next_segment() {
2194 let resolver = make_resolver_tool_calls();
2195 let (accessor, has_optional) =
2198 swift_build_accessor("choices[0].message.tool_calls[0].function.name", "result", &resolver);
2199 assert!(
2202 accessor.contains("tool_calls()?[0]"),
2203 "expected `tool_calls()?[0]` for optional tool_calls, got: {accessor}"
2204 );
2205 assert!(
2207 !accessor.contains("?[0]?"),
2208 "must not emit trailing `?` after subscript index: {accessor}"
2209 );
2210 assert!(has_optional, "expected has_optional=true for optional field chain");
2212 assert!(
2214 accessor.contains("[0].function()"),
2215 "expected `.function()` (non-optional) after subscript: {accessor}"
2216 );
2217 }
2218}