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 eprintln!(
606 "[swift debug] fixture={} call={:?} result_is_bytes={} any_override_bytes={} overrides={}",
607 fixture.id,
608 fixture.call,
609 call_config.result_is_bytes,
610 call_config.overrides.values().any(|o| o.result_is_bytes),
611 call_config.overrides.len()
612 );
613 let result_is_simple = call_config.result_is_simple
614 || call_overrides.is_some_and(|o| o.result_is_simple)
615 || result_is_simple
616 || result_is_bytes_any_lang;
617 let result_is_array = call_config.result_is_array;
618 let result_is_option = call_config.result_is_option || call_overrides.is_some_and(|o| o.result_is_option);
623
624 let method_name = fixture.id.to_upper_camel_case();
625 let description = &fixture.description;
626 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
627 let is_async = call_config.r#async;
628
629 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
631 let collect_snippet_opt = if is_streaming && !expects_error {
632 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(lang, result_var, "chunks")
633 } else {
634 None
635 };
636 if is_streaming && !expects_error && collect_snippet_opt.is_none() {
643 if is_async {
644 let _ = writeln!(out, " func test{method_name}() async throws {{");
645 } else {
646 let _ = writeln!(out, " func test{method_name}() throws {{");
647 }
648 let _ = writeln!(out, " // {description}");
649 let _ = writeln!(
650 out,
651 " try XCTSkipIf(true, \"swift: streaming chunk collection is not yet supported via the swift-bridge surface (fixture: {})\")",
652 fixture.id
653 );
654 let _ = writeln!(out, " }}");
655 return;
656 }
657 let collect_snippet = collect_snippet_opt.unwrap_or_default();
658
659 let has_unresolvable_json_object_arg = {
666 let options_via = call_overrides.and_then(|o| o.options_via.as_deref());
667 options_via.is_none() && args.iter().any(|a| a.arg_type == "json_object" && a.name != "config")
668 };
669
670 if has_unresolvable_json_object_arg {
671 if is_async {
672 let _ = writeln!(out, " func test{method_name}() async throws {{");
673 } else {
674 let _ = writeln!(out, " func test{method_name}() throws {{");
675 }
676 let _ = writeln!(out, " // {description}");
677 let _ = writeln!(
678 out,
679 " try XCTSkipIf(true, \"swift: json_object request construction requires options_via configuration (fixture: {})\");",
680 fixture.id
681 );
682 let _ = writeln!(out, " }}");
683 return;
684 }
685
686 let mut visitor_setup_lines: Vec<String> = Vec::new();
690 let visitor_handle_expr: Option<String> = fixture
691 .visitor
692 .as_ref()
693 .map(|spec| super::swift_visitors::build_swift_visitor(&mut visitor_setup_lines, spec, &fixture.id));
694
695 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
699
700 let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
705 let per_call = call_overrides.map(|o| &o.enum_fields);
706 if let Some(pc) = per_call {
707 if !pc.is_empty() {
708 let mut merged = enum_fields.clone();
709 merged.extend(pc.keys().cloned());
710 std::borrow::Cow::Owned(merged)
711 } else {
712 std::borrow::Cow::Borrowed(enum_fields)
713 }
714 } else {
715 std::borrow::Cow::Borrowed(enum_fields)
716 }
717 };
718
719 let options_via_str: Option<&str> = call_overrides.and_then(|o| o.options_via.as_deref());
720 let options_type_str: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
721 let handle_config_fn_owned: Option<String> = call_config
725 .overrides
726 .get("c")
727 .and_then(|c| c.c_engine_factory.as_deref())
728 .map(|ty| format!("{}_from_json", ty.to_snake_case()).to_lower_camel_case());
729 let (mut setup_lines, args_str) = build_args_and_setup(
730 &fixture.input,
731 args,
732 &fixture.id,
733 fixture.has_host_root_route(),
734 &function_name,
735 options_via_str,
736 options_type_str,
737 handle_config_fn_owned.as_deref(),
738 visitor_handle_expr.as_deref(),
739 );
740 if !visitor_setup_lines.is_empty() {
742 visitor_setup_lines.extend(setup_lines);
743 setup_lines = visitor_setup_lines;
744 }
745
746 let args_str = if extra_args.is_empty() {
748 args_str
749 } else if args_str.is_empty() {
750 extra_args.join(", ")
751 } else {
752 format!("{args_str}, {}", extra_args.join(", "))
753 };
754
755 let has_mock = fixture.mock_response.is_some();
760 let (call_setup, call_expr) = if let Some(_factory) = client_factory {
761 let env_key = format!("MOCK_SERVER_{}", fixture.id.to_ascii_uppercase().replace('-', "_"));
762 let mock_url = if fixture.has_host_root_route() {
763 format!(
764 "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\")",
765 fixture.id
766 )
767 } else {
768 format!(
769 "ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\"",
770 fixture.id
771 )
772 };
773 let client_constructor = if has_mock {
774 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
775 } else {
776 if let Some(env_var) = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()) {
778 format!(
779 "let _apiKey = ProcessInfo.processInfo.environment[\"{env_var}\"]\n \
780 let _baseUrl: String? = _apiKey != nil ? nil : {mock_url}\n \
781 let _client = try DefaultClient(apiKey: _apiKey ?? \"test-key\", baseUrl: _baseUrl)"
782 )
783 } else {
784 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
785 }
786 };
787 let expr = if is_async {
788 format!("try await _client.{function_name}({args_str})")
789 } else {
790 format!("try _client.{function_name}({args_str})")
791 };
792 (Some(client_constructor), expr)
793 } else {
794 let expr = if is_async {
796 format!("try await {function_name}({args_str})")
797 } else {
798 format!("try {function_name}({args_str})")
799 };
800 (None, expr)
801 };
802 let _ = function_name;
804
805 if is_async {
806 let _ = writeln!(out, " func test{method_name}() async throws {{");
807 } else {
808 let _ = writeln!(out, " func test{method_name}() throws {{");
809 }
810 let _ = writeln!(out, " // {description}");
811
812 if expects_error {
813 if is_async {
817 let _ = writeln!(out, " do {{");
822 for line in &setup_lines {
823 let _ = writeln!(out, " {line}");
824 }
825 if let Some(setup) = &call_setup {
826 let _ = writeln!(out, " {setup}");
827 }
828 let _ = writeln!(out, " _ = {call_expr}");
829 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
830 let _ = writeln!(out, " }} catch {{");
831 let _ = writeln!(out, " // success");
832 let _ = writeln!(out, " }}");
833 } else {
834 let _ = writeln!(out, " do {{");
841 for line in &setup_lines {
842 let _ = writeln!(out, " {line}");
843 }
844 if let Some(setup) = &call_setup {
845 let _ = writeln!(out, " {setup}");
846 }
847 let _ = writeln!(out, " _ = {call_expr}");
848 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
849 let _ = writeln!(out, " }} catch {{");
850 let _ = writeln!(out, " // success");
851 let _ = writeln!(out, " }}");
852 }
853 let _ = writeln!(out, " }}");
854 return;
855 }
856
857 for line in &setup_lines {
858 let _ = writeln!(out, " {line}");
859 }
860
861 if let Some(setup) = &call_setup {
863 let _ = writeln!(out, " {setup}");
864 }
865
866 let _ = writeln!(out, " let {result_var} = {call_expr}");
867
868 if !collect_snippet.is_empty() {
871 for line in collect_snippet.lines() {
872 let _ = writeln!(out, " {line}");
873 }
874 }
875
876 for assertion in &fixture.assertions {
877 render_assertion(
878 out,
879 assertion,
880 result_var,
881 field_resolver,
882 result_is_simple,
883 result_is_array,
884 result_is_option,
885 &effective_enum_fields,
886 is_streaming,
887 );
888 }
889
890 let _ = writeln!(out, " }}");
891}
892
893#[allow(clippy::too_many_arguments)]
894fn build_args_and_setup(
908 input: &serde_json::Value,
909 args: &[crate::config::ArgMapping],
910 fixture_id: &str,
911 has_host_root_route: bool,
912 function_name: &str,
913 options_via: Option<&str>,
914 options_type: Option<&str>,
915 handle_config_fn: Option<&str>,
916 visitor_handle_expr: Option<&str>,
917) -> (Vec<String>, String) {
918 if args.is_empty() {
919 return (Vec::new(), String::new());
920 }
921
922 let mut setup_lines: Vec<String> = Vec::new();
923 let mut parts: Vec<String> = Vec::new();
924
925 let later_emits: Vec<bool> = (0..args.len())
930 .map(|i| {
931 args.iter().skip(i + 1).any(|a| {
932 let f = a.field.strip_prefix("input.").unwrap_or(&a.field);
933 let v = input.get(f);
934 let has_value = matches!(v, Some(x) if !x.is_null());
935 has_value || !a.optional || (a.arg_type == "json_object" && a.name == "config")
936 })
937 })
938 .collect();
939
940 for (idx, arg) in args.iter().enumerate() {
941 if arg.arg_type == "mock_url" {
942 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_ascii_uppercase().replace('-', "_"));
943 let url_expr = if has_host_root_route {
944 format!(
945 "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\")"
946 )
947 } else {
948 format!("ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"")
949 };
950 setup_lines.push(format!("let {} = {url_expr}", arg.name));
951 parts.push(arg.name.clone());
952 continue;
953 }
954
955 if arg.arg_type == "handle" {
956 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
957 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
958 let config_val = input.get(field);
959 let has_config = config_val
960 .is_some_and(|v| !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty())));
961 if has_config {
962 if let Some(from_json_fn) = handle_config_fn {
963 let json_str = serde_json::to_string(config_val.unwrap()).unwrap_or_default();
964 let escaped = escape_swift_str(&json_str);
965 let config_var = format!("{}Config", arg.name.to_lower_camel_case());
966 setup_lines.push(format!("let {config_var} = try {from_json_fn}(\"{escaped}\")"));
967 setup_lines.push(format!("let {var_name} = try createEngine({config_var})"));
968 } else {
969 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
970 }
971 } else {
972 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
973 }
974 parts.push(var_name);
975 continue;
976 }
977
978 if arg.arg_type == "bytes" {
983 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
984 let val = input.get(field);
985 match val {
986 None | Some(serde_json::Value::Null) if arg.optional => {
987 if later_emits[idx] {
988 parts.push("nil".to_string());
989 }
990 }
991 None | Some(serde_json::Value::Null) => {
992 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
993 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
994 parts.push(var_name);
995 }
996 Some(serde_json::Value::String(s)) => {
997 let escaped = escape_swift(s);
998 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
999 let data_var = format!("{}Data", arg.name.to_lower_camel_case());
1000 setup_lines.push(format!(
1001 "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
1002 ));
1003 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1004 setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
1005 parts.push(var_name);
1006 }
1007 Some(serde_json::Value::Array(arr)) => {
1008 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1009 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1010 for v in arr {
1011 if let Some(n) = v.as_u64() {
1012 setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
1013 }
1014 }
1015 parts.push(var_name);
1016 }
1017 Some(other) => {
1018 let json_str = serde_json::to_string(other).unwrap_or_default();
1020 let escaped = escape_swift(&json_str);
1021 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1022 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1023 setup_lines.push(format!(
1024 "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
1025 ));
1026 parts.push(var_name);
1027 }
1028 }
1029 continue;
1030 }
1031
1032 let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
1038 let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
1039 if is_config_arg && !is_batch_fn {
1040 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1041 let val = input.get(field);
1042 let json_str = match val {
1043 None | Some(serde_json::Value::Null) => "{}".to_string(),
1044 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1045 };
1046 let escaped = escape_swift(&json_str);
1047 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
1048 let from_json_fn = if let Some(type_name) = options_type {
1050 format!("{}FromJson", type_name.to_lower_camel_case())
1051 } else {
1052 "extractionConfigFromJson".to_string()
1053 };
1054 setup_lines.push(format!("let {var_name} = try {from_json_fn}(\"{escaped}\")"));
1055 parts.push(var_name);
1056 continue;
1057 }
1058
1059 if arg.arg_type == "json_object" && options_via == Some("from_json") {
1066 if let Some(type_name) = options_type {
1067 let resolved_val = super::resolve_field(input, &arg.field);
1068 let json_str = match resolved_val {
1069 serde_json::Value::Null => "{}".to_string(),
1070 v => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1071 };
1072 let escaped = escape_swift(&json_str);
1073 let var_name = format!("_{}", arg.name.to_lower_camel_case());
1074 if let Some(handle_expr) = visitor_handle_expr {
1075 let with_visitor_fn = format!("{}FromJsonWithVisitor", type_name.to_lower_camel_case());
1080 let handle_var = format!("_visitorHandle_{}", var_name.trim_start_matches('_'));
1081 setup_lines.push(format!("let {handle_var} = {handle_expr}"));
1082 setup_lines.push(format!(
1083 "let {var_name} = try {with_visitor_fn}(\"{escaped}\", {handle_var})"
1084 ));
1085 } else {
1086 let from_json_fn = format!("{}FromJson", type_name.to_lower_camel_case());
1087 setup_lines.push(format!("let {var_name} = try {from_json_fn}(\"{escaped}\")"));
1088 }
1089 parts.push(var_name);
1090 continue;
1091 }
1092 }
1093
1094 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1095 let val = input.get(field);
1096 match val {
1097 None | Some(serde_json::Value::Null) if arg.optional => {
1098 if later_emits[idx] {
1102 parts.push("nil".to_string());
1103 }
1104 }
1105 None | Some(serde_json::Value::Null) => {
1106 let default_val = match arg.arg_type.as_str() {
1107 "string" => "\"\"".to_string(),
1108 "int" | "integer" => "0".to_string(),
1109 "float" | "number" => "0.0".to_string(),
1110 "bool" | "boolean" => "false".to_string(),
1111 _ => "nil".to_string(),
1112 };
1113 parts.push(default_val);
1114 }
1115 Some(v) => {
1116 parts.push(json_to_swift(v));
1117 }
1118 }
1119 }
1120
1121 (setup_lines, parts.join(", "))
1122}
1123
1124#[allow(clippy::too_many_arguments)]
1125fn render_assertion(
1126 out: &mut String,
1127 assertion: &Assertion,
1128 result_var: &str,
1129 field_resolver: &FieldResolver,
1130 result_is_simple: bool,
1131 result_is_array: bool,
1132 result_is_option: bool,
1133 enum_fields: &HashSet<String>,
1134 is_streaming: bool,
1135) {
1136 let bare_result_is_option = result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1141 if let Some(f) = &assertion.field {
1146 let is_streaming_usage_path =
1147 is_streaming && (f == "usage" || (f.starts_with("usage.") || f.starts_with("usage[")));
1148 if !f.is_empty()
1149 && (crate::codegen::streaming_assertions::is_streaming_virtual_field(f) || is_streaming_usage_path)
1150 {
1151 if let Some(expr) =
1152 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "swift", "chunks")
1153 {
1154 let line = match assertion.assertion_type.as_str() {
1155 "count_min" => {
1156 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1157 format!(" XCTAssertGreaterThanOrEqual(chunks.count, {n})\n")
1158 } else {
1159 String::new()
1160 }
1161 }
1162 "count_equals" => {
1163 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1164 format!(" XCTAssertEqual(chunks.count, {n})\n")
1165 } else {
1166 String::new()
1167 }
1168 }
1169 "equals" => {
1170 if let Some(serde_json::Value::String(s)) = &assertion.value {
1171 let escaped = escape_swift(s);
1172 format!(" XCTAssertEqual({expr}, \"{escaped}\")\n")
1173 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1174 format!(" XCTAssertEqual({expr}, {b})\n")
1175 } else {
1176 String::new()
1177 }
1178 }
1179 "not_empty" => {
1180 format!(" XCTAssertFalse({expr}.isEmpty, \"expected non-empty\")\n")
1181 }
1182 "is_empty" => {
1183 format!(" XCTAssertTrue({expr}.isEmpty, \"expected empty\")\n")
1184 }
1185 "is_true" => {
1186 format!(" XCTAssertTrue({expr})\n")
1187 }
1188 "is_false" => {
1189 format!(" XCTAssertFalse({expr})\n")
1190 }
1191 "greater_than" => {
1192 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1193 format!(" XCTAssertGreaterThan(chunks.count, {n})\n")
1194 } else {
1195 String::new()
1196 }
1197 }
1198 "contains" => {
1199 if let Some(serde_json::Value::String(s)) = &assertion.value {
1200 let escaped = escape_swift(s);
1201 format!(
1202 " XCTAssertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1203 )
1204 } else {
1205 String::new()
1206 }
1207 }
1208 _ => format!(
1209 " // streaming field '{f}': assertion type '{}' not rendered\n",
1210 assertion.assertion_type
1211 ),
1212 };
1213 if !line.is_empty() {
1214 out.push_str(&line);
1215 }
1216 }
1217 return;
1218 }
1219 }
1220
1221 if let Some(f) = &assertion.field {
1223 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1224 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1225 return;
1226 }
1227 }
1228
1229 if let Some(f) = &assertion.field {
1234 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1235 let _ = writeln!(
1236 out,
1237 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
1238 );
1239 return;
1240 }
1241 }
1242
1243 let field_is_enum = assertion
1245 .field
1246 .as_deref()
1247 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1248
1249 let field_is_optional = assertion.field.as_deref().is_some_and(|f| {
1250 !f.is_empty() && (field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f)))
1251 });
1252 let field_is_array = assertion.field.as_deref().is_some_and(|f| {
1253 !f.is_empty()
1254 && (field_resolver.is_array(f)
1255 || field_resolver.is_array(field_resolver.resolve(f))
1256 || field_resolver.is_collection_root(f)
1257 || field_resolver.is_collection_root(field_resolver.resolve(f)))
1258 });
1259
1260 let field_expr_raw = if result_is_simple {
1261 result_var.to_string()
1262 } else {
1263 match &assertion.field {
1264 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
1265 _ => result_var.to_string(),
1266 }
1267 };
1268
1269 let local_suffix = {
1279 use std::hash::{Hash, Hasher};
1280 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1281 assertion.field.hash(&mut hasher);
1282 assertion
1283 .value
1284 .as_ref()
1285 .map(|v| v.to_string())
1286 .unwrap_or_default()
1287 .hash(&mut hasher);
1288 format!(
1289 "{}_{:x}",
1290 assertion.assertion_type.replace(['-', '.'], "_"),
1291 hasher.finish() & 0xffff_ffff,
1292 )
1293 };
1294 let (vec_setup, field_expr, is_map_subscript) = materialise_vec_temporaries(&field_expr_raw, &local_suffix);
1295 let field_uses_traversal = assertion.field.as_deref().is_some_and(|f| f.contains("[]."));
1300 let traversal_skips_field_expr = field_uses_traversal
1301 && matches!(
1302 assertion.assertion_type.as_str(),
1303 "contains" | "not_contains" | "not_empty" | "is_empty"
1304 );
1305 if !traversal_skips_field_expr {
1306 for line in &vec_setup {
1307 let _ = writeln!(out, " {line}");
1308 }
1309 }
1310
1311 let accessor_is_optional = field_expr.contains("?.");
1317
1318 let string_expr = if is_map_subscript {
1327 format!("({field_expr} ?? \"\")")
1331 } else if field_is_enum && (field_is_optional || accessor_is_optional) {
1332 format!("({field_expr}?.toString() ?? \"\")")
1335 } else if field_is_enum {
1336 format!("{field_expr}.toString()")
1341 } else if field_is_optional {
1342 format!("({field_expr}?.toString() ?? \"\")")
1344 } else if accessor_is_optional {
1345 format!("({field_expr}.toString() ?? \"\")")
1348 } else {
1349 format!("{field_expr}.toString()")
1350 };
1351
1352 match assertion.assertion_type.as_str() {
1353 "equals" => {
1354 if let Some(expected) = &assertion.value {
1355 let swift_val = json_to_swift(expected);
1356 if expected.is_string() {
1357 if field_is_enum {
1358 let trim_expr =
1362 format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)");
1363 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1364 } else {
1365 let trim_expr =
1370 format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)");
1371 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1372 }
1373 } else {
1374 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
1375 }
1376 }
1377 }
1378 "contains" => {
1379 if let Some(expected) = &assertion.value {
1380 let swift_val = json_to_swift(expected);
1381 let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
1384 if result_is_simple && result_is_array && no_field {
1385 let _ = writeln!(
1388 out,
1389 " XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1390 );
1391 } else {
1392 let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1394 if let Some(dot) = f.find("[].") {
1395 let array_part = &f[..dot];
1396 let elem_part = &f[dot + 3..];
1397 let line = swift_traversal_contains_assert(
1398 array_part,
1399 elem_part,
1400 f,
1401 &swift_val,
1402 result_var,
1403 false,
1404 &format!("expected to contain: \\({swift_val})"),
1405 enum_fields,
1406 field_resolver,
1407 );
1408 let _ = writeln!(out, "{line}");
1409 true
1410 } else {
1411 false
1412 }
1413 } else {
1414 false
1415 };
1416 if !traversal_handled {
1417 let field_is_array = assertion
1419 .field
1420 .as_deref()
1421 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1422 if field_is_array {
1423 let contains_expr =
1424 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1425 let _ = writeln!(
1426 out,
1427 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1428 );
1429 } else if field_is_enum {
1430 let _ = writeln!(
1433 out,
1434 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1435 );
1436 } else {
1437 let _ = writeln!(
1438 out,
1439 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1440 );
1441 }
1442 }
1443 }
1444 }
1445 }
1446 "contains_all" => {
1447 if let Some(values) = &assertion.values {
1448 if let Some(f) = assertion.field.as_deref() {
1450 if let Some(dot) = f.find("[].") {
1451 let array_part = &f[..dot];
1452 let elem_part = &f[dot + 3..];
1453 for val in values {
1454 let swift_val = json_to_swift(val);
1455 let line = swift_traversal_contains_assert(
1456 array_part,
1457 elem_part,
1458 f,
1459 &swift_val,
1460 result_var,
1461 false,
1462 &format!("expected to contain: \\({swift_val})"),
1463 enum_fields,
1464 field_resolver,
1465 );
1466 let _ = writeln!(out, "{line}");
1467 }
1468 } else {
1470 let field_is_array = field_resolver.is_array(field_resolver.resolve(f));
1472 if field_is_array {
1473 let contains_expr =
1474 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1475 for val in values {
1476 let swift_val = json_to_swift(val);
1477 let _ = writeln!(
1478 out,
1479 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1480 );
1481 }
1482 } else if field_is_enum {
1483 for val in values {
1486 let swift_val = json_to_swift(val);
1487 let _ = writeln!(
1488 out,
1489 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1490 );
1491 }
1492 } else {
1493 for val in values {
1494 let swift_val = json_to_swift(val);
1495 let _ = writeln!(
1496 out,
1497 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1498 );
1499 }
1500 }
1501 }
1502 } else {
1503 for val in values {
1505 let swift_val = json_to_swift(val);
1506 let _ = writeln!(
1507 out,
1508 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1509 );
1510 }
1511 }
1512 }
1513 }
1514 "not_contains" => {
1515 if let Some(expected) = &assertion.value {
1516 let swift_val = json_to_swift(expected);
1517 let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1519 if let Some(dot) = f.find("[].") {
1520 let array_part = &f[..dot];
1521 let elem_part = &f[dot + 3..];
1522 let line = swift_traversal_contains_assert(
1523 array_part,
1524 elem_part,
1525 f,
1526 &swift_val,
1527 result_var,
1528 true,
1529 &format!("expected NOT to contain: \\({swift_val})"),
1530 enum_fields,
1531 field_resolver,
1532 );
1533 let _ = writeln!(out, "{line}");
1534 true
1535 } else {
1536 false
1537 }
1538 } else {
1539 false
1540 };
1541 if !traversal_handled {
1542 let _ = writeln!(
1543 out,
1544 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
1545 );
1546 }
1547 }
1548 }
1549 "not_empty" => {
1550 let traversal_not_empty_handled = if let Some(f) = assertion.field.as_deref() {
1557 if let Some(dot) = f.find("[].") {
1558 let array_part = &f[..dot];
1559 let elem_part = &f[dot + 3..];
1560 let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1561 let resolved_full = field_resolver.resolve(f);
1562 let resolved_elem_part = resolved_full
1563 .find("[].")
1564 .map(|d| &resolved_full[d + 3..])
1565 .unwrap_or(elem_part);
1566 let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1567 let elem_is_enum = enum_fields.contains(f) || enum_fields.contains(resolved_full);
1568 let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1569 || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1570 let elem_str = if elem_is_enum {
1571 format!("{elem_accessor}.to_string().toString()")
1572 } else if elem_is_optional {
1573 format!("({elem_accessor}?.toString() ?? \"\")")
1574 } else {
1575 format!("{elem_accessor}.toString()")
1576 };
1577 let _ = writeln!(
1578 out,
1579 " XCTAssertTrue({array_accessor}.contains(where: {{ !{elem_str}.isEmpty }}), \"expected non-empty value\")"
1580 );
1581 true
1582 } else {
1583 false
1584 }
1585 } else {
1586 false
1587 };
1588 if !traversal_not_empty_handled {
1589 if bare_result_is_option {
1590 let _ = writeln!(out, " XCTAssertNotNil({result_var}, \"expected non-nil value\")");
1591 } else if field_is_optional {
1592 let _ = writeln!(out, " XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
1593 } else if field_is_array {
1594 let _ = writeln!(
1595 out,
1596 " XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
1597 );
1598 } else if result_is_simple {
1599 let _ = writeln!(
1601 out,
1602 " XCTAssertFalse({result_var}.isEmpty, \"expected non-empty value\")"
1603 );
1604 } else {
1605 let len_expr = if accessor_is_optional {
1614 format!("({field_expr}.count ?? 0)")
1615 } else {
1616 format!("{field_expr}.count")
1617 };
1618 let _ = writeln!(
1619 out,
1620 " XCTAssertGreaterThan({len_expr}, 0, \"expected non-empty value\")"
1621 );
1622 }
1623 }
1624 }
1625 "is_empty" => {
1626 if bare_result_is_option {
1627 let _ = writeln!(out, " XCTAssertNil({result_var}, \"expected nil value\")");
1628 } else if field_is_optional {
1629 let _ = writeln!(out, " XCTAssertNil({field_expr}, \"expected nil value\")");
1630 } else if field_is_array {
1631 let _ = writeln!(
1632 out,
1633 " XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
1634 );
1635 } else {
1636 let len_expr = if accessor_is_optional {
1638 format!("({field_expr}.count ?? 0)")
1639 } else {
1640 format!("{field_expr}.count")
1641 };
1642 let _ = writeln!(out, " XCTAssertEqual({len_expr}, 0, \"expected empty value\")");
1643 }
1644 }
1645 "contains_any" => {
1646 if let Some(values) = &assertion.values {
1647 let checks: Vec<String> = values
1648 .iter()
1649 .map(|v| {
1650 let swift_val = json_to_swift(v);
1651 format!("{string_expr}.contains({swift_val})")
1652 })
1653 .collect();
1654 let joined = checks.join(" || ");
1655 let _ = writeln!(
1656 out,
1657 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
1658 );
1659 }
1660 }
1661 "greater_than" => {
1662 if let Some(val) = &assertion.value {
1663 let swift_val = json_to_swift(val);
1664 let field_is_optional = accessor_is_optional
1667 || assertion.field.as_deref().is_some_and(|f| {
1668 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1669 });
1670 let compare_expr = if field_is_optional {
1671 format!("({field_expr} ?? 0)")
1672 } else {
1673 field_expr.clone()
1674 };
1675 let _ = writeln!(out, " XCTAssertGreaterThan({compare_expr}, {swift_val})");
1676 }
1677 }
1678 "less_than" => {
1679 if let Some(val) = &assertion.value {
1680 let swift_val = json_to_swift(val);
1681 let field_is_optional = accessor_is_optional
1682 || assertion.field.as_deref().is_some_and(|f| {
1683 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1684 });
1685 let compare_expr = if field_is_optional {
1686 format!("({field_expr} ?? 0)")
1687 } else {
1688 field_expr.clone()
1689 };
1690 let _ = writeln!(out, " XCTAssertLessThan({compare_expr}, {swift_val})");
1691 }
1692 }
1693 "greater_than_or_equal" => {
1694 if let Some(val) = &assertion.value {
1695 let swift_val = json_to_swift(val);
1696 let field_is_optional = accessor_is_optional
1699 || assertion.field.as_deref().is_some_and(|f| {
1700 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1701 });
1702 let compare_expr = if field_is_optional {
1703 format!("({field_expr} ?? 0)")
1704 } else {
1705 field_expr.clone()
1706 };
1707 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
1708 }
1709 }
1710 "less_than_or_equal" => {
1711 if let Some(val) = &assertion.value {
1712 let swift_val = json_to_swift(val);
1713 let field_is_optional = accessor_is_optional
1714 || assertion.field.as_deref().is_some_and(|f| {
1715 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1716 });
1717 let compare_expr = if field_is_optional {
1718 format!("({field_expr} ?? 0)")
1719 } else {
1720 field_expr.clone()
1721 };
1722 let _ = writeln!(out, " XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
1723 }
1724 }
1725 "starts_with" => {
1726 if let Some(expected) = &assertion.value {
1727 let swift_val = json_to_swift(expected);
1728 let _ = writeln!(
1729 out,
1730 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1731 );
1732 }
1733 }
1734 "ends_with" => {
1735 if let Some(expected) = &assertion.value {
1736 let swift_val = json_to_swift(expected);
1737 let _ = writeln!(
1738 out,
1739 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1740 );
1741 }
1742 }
1743 "min_length" => {
1744 if let Some(val) = &assertion.value {
1745 if let Some(n) = val.as_u64() {
1746 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1749 }
1750 }
1751 }
1752 "max_length" => {
1753 if let Some(val) = &assertion.value {
1754 if let Some(n) = val.as_u64() {
1755 let _ = writeln!(out, " XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1756 }
1757 }
1758 }
1759 "count_min" => {
1760 if let Some(val) = &assertion.value {
1761 if let Some(n) = val.as_u64() {
1762 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1766 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1767 }
1768 }
1769 }
1770 "count_equals" => {
1771 if let Some(val) = &assertion.value {
1772 if let Some(n) = val.as_u64() {
1773 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1774 let _ = writeln!(out, " XCTAssertEqual({count_expr}, {n})");
1775 }
1776 }
1777 }
1778 "is_true" => {
1779 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
1780 }
1781 "is_false" => {
1782 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
1783 }
1784 "matches_regex" => {
1785 if let Some(expected) = &assertion.value {
1786 let swift_val = json_to_swift(expected);
1787 let _ = writeln!(
1788 out,
1789 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1790 );
1791 }
1792 }
1793 "not_error" => {
1794 }
1796 "error" => {
1797 }
1799 "method_result" => {
1800 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
1801 }
1802 other => {
1803 panic!("Swift e2e generator: unsupported assertion type: {other}");
1804 }
1805 }
1806}
1807
1808fn materialise_vec_temporaries(expr: &str, name_suffix: &str) -> (Vec<String>, String, bool) {
1833 let Some(idx) = expr.find("()[") else {
1834 return (Vec::new(), expr.to_string(), false);
1835 };
1836 let after_open = idx + 3; let Some(close_rel) = expr[after_open..].find(']') else {
1838 return (Vec::new(), expr.to_string(), false);
1839 };
1840 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);
1845 let method = &expr[method_dot + 1..idx];
1846 let local = format!("_vec_{}_{}", method, name_suffix);
1847
1848 let inner = subscript.trim_start_matches('[').trim_end_matches(']');
1853 let is_string_key = inner.starts_with('"') && inner.ends_with('"');
1854 let setup = if is_string_key {
1855 format!(
1856 "let {local} = (try? JSONSerialization.jsonObject(with: ({prefix}.toString() ?? \"{{}}\").data(using: .utf8)!) as? [String: String]) ?? [:]"
1857 )
1858 } else {
1859 format!("let {local} = {prefix}")
1860 };
1861
1862 let rewritten = format!("{local}{subscript}{tail}");
1863 (vec![setup], rewritten, is_string_key)
1864}
1865
1866fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1869 let resolved = field_resolver.resolve(field);
1870 let parts: Vec<&str> = resolved.split('.').collect();
1871
1872 let mut out = result_var.to_string();
1875 let mut has_optional = false;
1876 let mut path_so_far = String::new();
1877 let total = parts.len();
1878 for (i, part) in parts.iter().enumerate() {
1879 let is_leaf = i == total - 1;
1880 let (field_name, subscript): (&str, Option<&str>) = if let Some(bracket_pos) = part.find('[') {
1884 (&part[..bracket_pos], Some(&part[bracket_pos..]))
1885 } else {
1886 (part, None)
1887 };
1888
1889 if !path_so_far.is_empty() {
1890 path_so_far.push('.');
1891 }
1892 let base_path = {
1896 let mut p = path_so_far.clone();
1897 p.push_str(field_name);
1898 p
1899 };
1900 path_so_far.push_str(part);
1903
1904 out.push('.');
1905 out.push_str(field_name);
1906 if let Some(sub) = subscript {
1907 let field_is_optional = field_resolver.is_optional(&base_path);
1911 if field_is_optional {
1912 out.push_str("()?");
1913 has_optional = true;
1914 } else {
1915 out.push_str("()");
1916 }
1917 out.push_str(sub);
1918 } else {
1928 out.push_str("()");
1929 if !is_leaf && field_resolver.is_optional(&base_path) {
1932 out.push('?');
1933 has_optional = true;
1934 }
1935 }
1936 }
1937 (out, has_optional)
1938}
1939
1940#[allow(clippy::too_many_arguments)]
1962fn swift_traversal_contains_assert(
1963 array_part: &str,
1964 element_part: &str,
1965 full_field: &str,
1966 val_expr: &str,
1967 result_var: &str,
1968 negate: bool,
1969 msg: &str,
1970 enum_fields: &std::collections::HashSet<String>,
1971 field_resolver: &FieldResolver,
1972) -> String {
1973 let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1974 let resolved_full = field_resolver.resolve(full_field);
1975 let resolved_elem_part = resolved_full
1976 .find("[].")
1977 .map(|d| &resolved_full[d + 3..])
1978 .unwrap_or(element_part);
1979 let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1980 let elem_is_enum = enum_fields.contains(full_field) || enum_fields.contains(resolved_full);
1981 let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1982 || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1983 let elem_str = if elem_is_enum {
1984 format!("{elem_accessor}.toString()")
1987 } else if elem_is_optional {
1988 format!("({elem_accessor}?.toString() ?? \"\")")
1989 } else {
1990 format!("{elem_accessor}.toString()")
1991 };
1992 let assert_fn = if negate { "XCTAssertFalse" } else { "XCTAssertTrue" };
1993 format!(" {assert_fn}({array_accessor}.contains(where: {{ {elem_str}.contains({val_expr}) }}), \"{msg}\")")
1994}
1995
1996fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1997 let Some(f) = field else {
1998 return format!("{result_var}.map {{ $0.as_str().toString() }}");
1999 };
2000 let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
2001 format!("{accessor}?.map {{ $0.as_str().toString() }}")
2004}
2005
2006fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
2015 let Some(f) = field else {
2016 return format!("{result_var}.count");
2017 };
2018 let (accessor, mut has_optional) = swift_build_accessor(f, result_var, field_resolver);
2019 if field_resolver.is_optional(f) {
2021 has_optional = true;
2022 }
2023 if has_optional {
2024 if accessor.contains("?.") {
2027 format!("{accessor}.count ?? 0")
2028 } else {
2029 format!("({accessor}?.count ?? 0)")
2032 }
2033 } else {
2034 format!("{accessor}.count")
2035 }
2036}
2037
2038fn json_to_swift(value: &serde_json::Value) -> String {
2040 match value {
2041 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
2042 serde_json::Value::Bool(b) => b.to_string(),
2043 serde_json::Value::Number(n) => n.to_string(),
2044 serde_json::Value::Null => "nil".to_string(),
2045 serde_json::Value::Array(arr) => {
2046 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
2047 format!("[{}]", items.join(", "))
2048 }
2049 serde_json::Value::Object(_) => {
2050 let json_str = serde_json::to_string(value).unwrap_or_default();
2051 format!("\"{}\"", escape_swift(&json_str))
2052 }
2053 }
2054}
2055
2056fn escape_swift(s: &str) -> String {
2058 escape_swift_str(s)
2059}
2060
2061fn swift_first_class_field_supported(ty: &alef_core::ir::TypeRef) -> bool {
2066 use alef_core::ir::TypeRef;
2067 match ty {
2068 TypeRef::Primitive(_) | TypeRef::String => true,
2069 TypeRef::Optional(inner) => swift_first_class_field_supported(inner),
2070 _ => false,
2071 }
2072}
2073
2074fn build_swift_first_class_map(
2087 type_defs: &[alef_core::ir::TypeDef],
2088 e2e_config: &crate::config::E2eConfig,
2089) -> SwiftFirstClassMap {
2090 use alef_core::ir::TypeRef;
2091 let mut first_class_types: HashSet<String> = HashSet::new();
2092 let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2093 fn inner_named(ty: &TypeRef) -> Option<String> {
2094 match ty {
2095 TypeRef::Named(n) => Some(n.clone()),
2096 TypeRef::Optional(inner) | TypeRef::Vec(inner) => inner_named(inner),
2097 _ => None,
2098 }
2099 }
2100 for td in type_defs {
2101 let is_first_class = !td.is_opaque
2102 && td.has_serde
2103 && !td.fields.is_empty()
2104 && td.fields.iter().all(|f| swift_first_class_field_supported(&f.ty));
2105 if is_first_class {
2106 first_class_types.insert(td.name.clone());
2107 }
2108 let mut td_field_types: HashMap<String, String> = HashMap::new();
2109 for f in &td.fields {
2110 if let Some(named) = inner_named(&f.ty) {
2111 td_field_types.insert(f.name.clone(), named);
2112 }
2113 }
2114 if !td_field_types.is_empty() {
2115 field_types.insert(td.name.clone(), td_field_types);
2116 }
2117 }
2118 let root_type = if e2e_config.result_fields.is_empty() {
2122 None
2123 } else {
2124 let matches: Vec<&alef_core::ir::TypeDef> = type_defs
2125 .iter()
2126 .filter(|td| {
2127 let names: HashSet<&str> = td.fields.iter().map(|f| f.name.as_str()).collect();
2128 e2e_config.result_fields.iter().all(|rf| names.contains(rf.as_str()))
2129 })
2130 .collect();
2131 if matches.len() == 1 {
2132 Some(matches[0].name.clone())
2133 } else {
2134 None
2135 }
2136 };
2137 SwiftFirstClassMap {
2138 first_class_types,
2139 field_types,
2140 root_type,
2141 }
2142}
2143
2144#[cfg(test)]
2145mod tests {
2146 use super::*;
2147 use crate::field_access::FieldResolver;
2148 use std::collections::{HashMap, HashSet};
2149
2150 fn make_resolver_tool_calls() -> FieldResolver {
2151 let mut optional = HashSet::new();
2155 optional.insert("choices.message.tool_calls".to_string());
2156 let mut arrays = HashSet::new();
2157 arrays.insert("choices".to_string());
2158 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
2159 }
2160
2161 #[test]
2168 fn optional_vec_subscript_does_not_emit_trailing_question_mark_before_next_segment() {
2169 let resolver = make_resolver_tool_calls();
2170 let (accessor, has_optional) =
2173 swift_build_accessor("choices[0].message.tool_calls[0].function.name", "result", &resolver);
2174 assert!(
2177 accessor.contains("tool_calls()?[0]"),
2178 "expected `tool_calls()?[0]` for optional tool_calls, got: {accessor}"
2179 );
2180 assert!(
2182 !accessor.contains("?[0]?"),
2183 "must not emit trailing `?` after subscript index: {accessor}"
2184 );
2185 assert!(has_optional, "expected has_optional=true for optional field chain");
2187 assert!(
2189 accessor.contains("[0].function()"),
2190 "expected `.function()` (non-optional) after subscript: {accessor}"
2191 );
2192 }
2193}