1use crate::config::E2eConfig;
15use crate::escape::{escape_java as escape_swift_str, expand_fixture_templates, sanitize_filename, sanitize_ident};
16use crate::field_access::FieldResolver;
17use crate::fixture::{Assertion, Fixture, FixtureGroup, ValidationErrorExpectation};
18use alef_core::backend::GeneratedFile;
19use alef_core::config::ResolvedCrateConfig;
20use alef_core::hash::{self, CommentStyle};
21use alef_core::template_versions::toolchain;
22use anyhow::Result;
23use heck::{ToLowerCamelCase, ToUpperCamelCase};
24use std::collections::HashSet;
25use std::fmt::Write as FmtWrite;
26use std::path::PathBuf;
27
28use super::E2eCodegen;
29use super::client;
30
31pub struct SwiftE2eCodegen;
33
34impl E2eCodegen for SwiftE2eCodegen {
35 fn generate(
36 &self,
37 groups: &[FixtureGroup],
38 e2e_config: &E2eConfig,
39 config: &ResolvedCrateConfig,
40 _type_defs: &[alef_core::ir::TypeDef],
41 ) -> Result<Vec<GeneratedFile>> {
42 let lang = self.language_name();
43 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
44
45 let mut files = Vec::new();
46
47 let call = &e2e_config.call;
49 let overrides = call.overrides.get(lang);
50 let function_name = overrides
51 .and_then(|o| o.function.as_ref())
52 .cloned()
53 .unwrap_or_else(|| call.function.clone());
54 let result_var = &call.result_var;
55 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
56
57 let swift_pkg = e2e_config.resolve_package("swift");
59 let pkg_name = swift_pkg
60 .as_ref()
61 .and_then(|p| p.name.as_ref())
62 .cloned()
63 .unwrap_or_else(|| config.name.to_upper_camel_case());
64 let pkg_path = swift_pkg
65 .as_ref()
66 .and_then(|p| p.path.as_ref())
67 .cloned()
68 .unwrap_or_else(|| "../../packages/swift".to_string());
69 let pkg_version = swift_pkg
70 .as_ref()
71 .and_then(|p| p.version.as_ref())
72 .cloned()
73 .or_else(|| config.resolved_version())
74 .unwrap_or_else(|| "0.1.0".to_string());
75
76 let module_name = pkg_name.as_str();
78
79 let registry_url = config
83 .try_github_repo()
84 .map(|repo| {
85 let base = repo.trim_end_matches('/').trim_end_matches(".git");
86 format!("{base}.git")
87 })
88 .unwrap_or_else(|_| format!("https://example.invalid/{module_name}.git"));
89
90 files.push(GeneratedFile {
93 path: output_base.join("Package.swift"),
94 content: render_package_swift(module_name, ®istry_url, &pkg_path, &pkg_version, e2e_config.dep_mode),
95 generated_header: false,
96 });
97
98 let tests_base = normalize_path(&output_base.join(&pkg_path));
112
113 let field_resolver = FieldResolver::new(
114 &e2e_config.fields,
115 &e2e_config.fields_optional,
116 &e2e_config.result_fields,
117 &e2e_config.fields_array,
118 &e2e_config.fields_method_calls,
119 );
120
121 let client_factory: Option<&str> = overrides.and_then(|o| o.client_factory.as_deref());
123
124 for group in groups {
126 let active: Vec<&Fixture> = group
127 .fixtures
128 .iter()
129 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
130 .collect();
131
132 if active.is_empty() {
133 continue;
134 }
135
136 let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
137 let filename = format!("{class_name}.swift");
138 let content = render_test_file(
139 &group.category,
140 &active,
141 e2e_config,
142 module_name,
143 &class_name,
144 &function_name,
145 result_var,
146 &e2e_config.call.args,
147 &field_resolver,
148 result_is_simple,
149 &e2e_config.fields_enum,
150 client_factory,
151 );
152 files.push(GeneratedFile {
153 path: tests_base
154 .join("Tests")
155 .join(format!("{module_name}Tests"))
156 .join(filename),
157 content,
158 generated_header: true,
159 });
160 }
161
162 Ok(files)
163 }
164
165 fn language_name(&self) -> &'static str {
166 "swift"
167 }
168}
169
170fn render_package_swift(
175 module_name: &str,
176 registry_url: &str,
177 pkg_path: &str,
178 pkg_version: &str,
179 dep_mode: crate::config::DependencyMode,
180) -> String {
181 let min_macos = toolchain::SWIFT_MIN_MACOS;
182
183 let (dep_block, product_dep) = match dep_mode {
187 crate::config::DependencyMode::Registry => {
188 let dep = format!(r#" .package(url: "{registry_url}", from: "{pkg_version}")"#);
189 let pkg_id = registry_url
190 .trim_end_matches('/')
191 .trim_end_matches(".git")
192 .split('/')
193 .next_back()
194 .unwrap_or(module_name);
195 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
196 (dep, prod)
197 }
198 crate::config::DependencyMode::Local => {
199 let dep = format!(r#" .package(path: "{pkg_path}")"#);
200 let pkg_id = pkg_path
201 .trim_end_matches('/')
202 .split('/')
203 .next_back()
204 .unwrap_or(module_name);
205 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
206 (dep, prod)
207 }
208 };
209 let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
212 format!(
215 r#"// swift-tools-version: 6.0
216import PackageDescription
217
218let package = Package(
219 name: "E2eSwift",
220 platforms: [
221 .macOS(.v{min_macos_major}),
222 .iOS(.v14),
223 ],
224 dependencies: [
225{dep_block},
226 ],
227 targets: [
228 .testTarget(
229 name: "{module_name}Tests",
230 dependencies: [{product_dep}]
231 ),
232 ]
233)
234"#
235 )
236}
237
238#[allow(clippy::too_many_arguments)]
239fn render_test_file(
240 category: &str,
241 fixtures: &[&Fixture],
242 e2e_config: &E2eConfig,
243 module_name: &str,
244 class_name: &str,
245 function_name: &str,
246 result_var: &str,
247 args: &[crate::config::ArgMapping],
248 field_resolver: &FieldResolver,
249 result_is_simple: bool,
250 enum_fields: &HashSet<String>,
251 client_factory: Option<&str>,
252) -> String {
253 let needs_chdir = fixtures.iter().any(|f| {
260 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
261 call_config
262 .args
263 .iter()
264 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
265 });
266
267 let mut out = String::new();
268 out.push_str(&hash::header(CommentStyle::DoubleSlash));
269 let _ = writeln!(out, "import XCTest");
270 let _ = writeln!(out, "import Foundation");
271 let _ = writeln!(out, "import {module_name}");
272 let _ = writeln!(out, "import RustBridge");
273 let _ = writeln!(out);
274 let _ = writeln!(out, "/// E2e tests for category: {category}.");
275 let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
276
277 if needs_chdir {
278 let _ = writeln!(out, " override class func setUp() {{");
286 let _ = writeln!(out, " super.setUp()");
287 let _ = writeln!(out, " let _testDocs = URL(fileURLWithPath: #filePath)");
288 let _ = writeln!(out, " .deletingLastPathComponent() // <Module>Tests/");
289 let _ = writeln!(out, " .deletingLastPathComponent() // Tests/");
290 let _ = writeln!(out, " .deletingLastPathComponent() // swift/");
291 let _ = writeln!(out, " .deletingLastPathComponent() // packages/");
292 let _ = writeln!(out, " .deletingLastPathComponent() // <repo root>");
293 let _ = writeln!(
294 out,
295 " .appendingPathComponent(\"{}\")",
296 e2e_config.test_documents_dir
297 );
298 let _ = writeln!(
299 out,
300 " if FileManager.default.fileExists(atPath: _testDocs.path) {{"
301 );
302 let _ = writeln!(
303 out,
304 " FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
305 );
306 let _ = writeln!(out, " }}");
307 let _ = writeln!(out, " }}");
308 let _ = writeln!(out);
309 }
310
311 for fixture in fixtures {
312 if fixture.is_http_test() {
313 render_http_test_method(&mut out, fixture);
314 } else {
315 render_test_method(
316 &mut out,
317 fixture,
318 e2e_config,
319 function_name,
320 result_var,
321 args,
322 field_resolver,
323 result_is_simple,
324 enum_fields,
325 client_factory,
326 );
327 }
328 let _ = writeln!(out);
329 }
330
331 let _ = writeln!(out, "}}");
332 out
333}
334
335struct SwiftTestClientRenderer;
342
343impl client::TestClientRenderer for SwiftTestClientRenderer {
344 fn language_name(&self) -> &'static str {
345 "swift"
346 }
347
348 fn sanitize_test_name(&self, id: &str) -> String {
349 sanitize_ident(id).to_upper_camel_case()
351 }
352
353 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
359 let _ = writeln!(out, " /// {description}");
360 let _ = writeln!(out, " func test{fn_name}() throws {{");
361 if let Some(reason) = skip_reason {
362 let escaped = escape_swift(reason);
363 let _ = writeln!(out, " try XCTSkipIf(true, \"{escaped}\")");
364 }
365 }
366
367 fn render_test_close(&self, out: &mut String) {
368 let _ = writeln!(out, " }}");
369 }
370
371 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
378 let method = ctx.method.to_uppercase();
379 let fixture_path = escape_swift(ctx.path);
380
381 let _ = writeln!(
382 out,
383 " let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
384 );
385 let _ = writeln!(
386 out,
387 " var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
388 );
389 let _ = writeln!(out, " _req.httpMethod = \"{method}\"");
390
391 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
393 header_pairs.sort_by_key(|(k, _)| k.as_str());
394 for (k, v) in &header_pairs {
395 let expanded_v = expand_fixture_templates(v);
396 let ek = escape_swift(k);
397 let ev = escape_swift(&expanded_v);
398 let _ = writeln!(out, " _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
399 }
400
401 if let Some(body) = ctx.body {
403 let json_str = serde_json::to_string(body).unwrap_or_default();
404 let escaped_body = escape_swift(&json_str);
405 let _ = writeln!(out, " _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
406 let _ = writeln!(
407 out,
408 " _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
409 );
410 }
411
412 let _ = writeln!(out, " var {}: HTTPURLResponse?", ctx.response_var);
413 let _ = writeln!(out, " var _responseData: Data?");
414 let _ = writeln!(out, " let _sema = DispatchSemaphore(value: 0)");
415 let _ = writeln!(
416 out,
417 " URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
418 );
419 let _ = writeln!(out, " {} = resp as? HTTPURLResponse", ctx.response_var);
420 let _ = writeln!(out, " _responseData = data");
421 let _ = writeln!(out, " _sema.signal()");
422 let _ = writeln!(out, " }}.resume()");
423 let _ = writeln!(out, " _sema.wait()");
424 let _ = writeln!(out, " let _resp = try XCTUnwrap({})", ctx.response_var);
425 }
426
427 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
428 let _ = writeln!(out, " XCTAssertEqual(_resp.statusCode, {status})");
429 }
430
431 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
432 let lower_name = name.to_lowercase();
433 let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
434 match expected {
435 "<<present>>" => {
436 let _ = writeln!(out, " XCTAssertNotNil({header_expr})");
437 }
438 "<<absent>>" => {
439 let _ = writeln!(out, " XCTAssertNil({header_expr})");
440 }
441 "<<uuid>>" => {
442 let _ = writeln!(out, " let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
443 let _ = writeln!(
444 out,
445 " XCTAssertNotNil(_hdrVal_{lower_name}.range(of: #\"^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$\"#, options: .regularExpression))"
446 );
447 }
448 exact => {
449 let escaped = escape_swift(exact);
450 let _ = writeln!(out, " XCTAssertEqual({header_expr}, \"{escaped}\")");
451 }
452 }
453 }
454
455 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
456 if let serde_json::Value::String(s) = expected {
457 let escaped = escape_swift(s);
458 let _ = writeln!(
459 out,
460 " let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
461 );
462 let _ = writeln!(
463 out,
464 " XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
465 );
466 } else {
467 let json_str = serde_json::to_string(expected).unwrap_or_default();
468 let escaped = escape_swift(&json_str);
469 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
470 let _ = writeln!(
471 out,
472 " let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
473 );
474 let _ = writeln!(
475 out,
476 " let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
477 );
478 let _ = writeln!(
479 out,
480 " XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
481 );
482 }
483 }
484
485 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
486 if let Some(obj) = expected.as_object() {
487 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
488 let _ = writeln!(
489 out,
490 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
491 );
492 for (key, val) in obj {
493 let escaped_key = escape_swift(key);
494 let swift_val = json_to_swift(val);
495 let _ = writeln!(
496 out,
497 " XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
498 );
499 }
500 }
501 }
502
503 fn render_assert_validation_errors(
504 &self,
505 out: &mut String,
506 _response_var: &str,
507 errors: &[ValidationErrorExpectation],
508 ) {
509 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
510 let _ = writeln!(
511 out,
512 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
513 );
514 let _ = writeln!(
515 out,
516 " let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
517 );
518 for ve in errors {
519 let escaped_msg = escape_swift(&ve.msg);
520 let _ = writeln!(
521 out,
522 " XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
523 );
524 }
525 }
526}
527
528fn render_http_test_method(out: &mut String, fixture: &Fixture) {
533 let Some(http) = &fixture.http else {
534 return;
535 };
536
537 if http.expected_response.status_code == 101 {
539 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
540 let description = fixture.description.replace('"', "\\\"");
541 let _ = writeln!(out, " /// {description}");
542 let _ = writeln!(out, " func test{method_name}() throws {{");
543 let _ = writeln!(
544 out,
545 " try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
546 );
547 let _ = writeln!(out, " }}");
548 return;
549 }
550
551 client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
552}
553
554#[allow(clippy::too_many_arguments)]
559fn render_test_method(
560 out: &mut String,
561 fixture: &Fixture,
562 e2e_config: &E2eConfig,
563 _function_name: &str,
564 _result_var: &str,
565 _args: &[crate::config::ArgMapping],
566 field_resolver: &FieldResolver,
567 result_is_simple: bool,
568 enum_fields: &HashSet<String>,
569 global_client_factory: Option<&str>,
570) {
571 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
573 let lang = "swift";
574 let call_overrides = call_config.overrides.get(lang);
575 let function_name = call_overrides
576 .and_then(|o| o.function.as_ref())
577 .cloned()
578 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
579 let client_factory: Option<&str> = call_overrides
581 .and_then(|o| o.client_factory.as_deref())
582 .or(global_client_factory);
583 let result_var = &call_config.result_var;
584 let args = &call_config.args;
585 let result_is_simple = call_config.result_is_simple
587 || call_overrides.is_some_and(|o| o.result_is_simple)
588 || result_is_simple;
589 let result_is_array = call_config.result_is_array;
590
591 let method_name = fixture.id.to_upper_camel_case();
592 let description = &fixture.description;
593 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
594 let is_async = call_config.r#async;
595
596 let has_unresolvable_json_object_arg = {
603 let options_via = call_overrides.and_then(|o| o.options_via.as_deref());
604 options_via.is_none()
605 && args
606 .iter()
607 .any(|a| a.arg_type == "json_object" && a.name != "config")
608 };
609
610 if has_unresolvable_json_object_arg {
611 if is_async {
612 let _ = writeln!(out, " func test{method_name}() async throws {{");
613 } else {
614 let _ = writeln!(out, " func test{method_name}() throws {{");
615 }
616 let _ = writeln!(out, " // {description}");
617 let _ = writeln!(
618 out,
619 " try XCTSkipIf(true, \"swift: json_object request construction requires options_via configuration (fixture: {})\");",
620 fixture.id
621 );
622 let _ = writeln!(out, " }}");
623 return;
624 }
625
626 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
630
631 let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
636 let per_call = call_overrides.map(|o| &o.enum_fields);
637 if let Some(pc) = per_call {
638 if !pc.is_empty() {
639 let mut merged = enum_fields.clone();
640 merged.extend(pc.keys().cloned());
641 std::borrow::Cow::Owned(merged)
642 } else {
643 std::borrow::Cow::Borrowed(enum_fields)
644 }
645 } else {
646 std::borrow::Cow::Borrowed(enum_fields)
647 }
648 };
649
650 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id, &function_name);
651
652 let args_str = if extra_args.is_empty() {
654 args_str
655 } else if args_str.is_empty() {
656 extra_args.join(", ")
657 } else {
658 format!("{args_str}, {}", extra_args.join(", "))
659 };
660
661 let has_mock = fixture.mock_response.is_some();
666 let (call_setup, call_expr) = if let Some(_factory) = client_factory {
667 let mock_url = format!(
668 "ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\"",
669 fixture.id
670 );
671 let client_constructor = if has_mock {
672 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
673 } else {
674 if let Some(env_var) = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()) {
676 format!(
677 "let _apiKey = ProcessInfo.processInfo.environment[\"{env_var}\"]\n \
678 let _baseUrl: String? = _apiKey != nil ? nil : {mock_url}\n \
679 let _client = try DefaultClient(apiKey: _apiKey ?? \"test-key\", baseUrl: _baseUrl)"
680 )
681 } else {
682 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
683 }
684 };
685 let expr = if is_async {
686 format!("try await _client.{function_name}({args_str})")
687 } else {
688 format!("try _client.{function_name}({args_str})")
689 };
690 (Some(client_constructor), expr)
691 } else {
692 let expr = if is_async {
694 format!("try await {function_name}({args_str})")
695 } else {
696 format!("try {function_name}({args_str})")
697 };
698 (None, expr)
699 };
700 let _ = function_name;
702
703 if is_async {
704 let _ = writeln!(out, " func test{method_name}() async throws {{");
705 } else {
706 let _ = writeln!(out, " func test{method_name}() throws {{");
707 }
708 let _ = writeln!(out, " // {description}");
709
710 for line in &setup_lines {
711 let _ = writeln!(out, " {line}");
712 }
713
714 if let Some(setup) = &call_setup {
716 let _ = writeln!(out, " {setup}");
717 }
718
719 if expects_error {
720 if is_async {
721 let _ = writeln!(out, " do {{");
726 let _ = writeln!(out, " _ = {call_expr}");
727 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
728 let _ = writeln!(out, " }} catch {{");
729 let _ = writeln!(out, " // success");
730 let _ = writeln!(out, " }}");
731 } else {
732 let _ = writeln!(out, " XCTAssertThrowsError({call_expr})");
733 }
734 let _ = writeln!(out, " }}");
735 return;
736 }
737
738 let _ = writeln!(out, " let {result_var} = {call_expr}");
739
740 for assertion in &fixture.assertions {
741 render_assertion(
742 out,
743 assertion,
744 result_var,
745 field_resolver,
746 result_is_simple,
747 result_is_array,
748 &effective_enum_fields,
749 );
750 }
751
752 let _ = writeln!(out, " }}");
753}
754
755fn build_args_and_setup(
769 input: &serde_json::Value,
770 args: &[crate::config::ArgMapping],
771 fixture_id: &str,
772 function_name: &str,
773) -> (Vec<String>, String) {
774 if args.is_empty() {
775 return (Vec::new(), String::new());
776 }
777
778 let mut setup_lines: Vec<String> = Vec::new();
779 let mut parts: Vec<String> = Vec::new();
780
781 let later_emits: Vec<bool> = (0..args.len())
786 .map(|i| {
787 args.iter().skip(i + 1).any(|a| {
788 let f = a.field.strip_prefix("input.").unwrap_or(&a.field);
789 let v = input.get(f);
790 let has_value = matches!(v, Some(x) if !x.is_null());
791 has_value || !a.optional || (a.arg_type == "json_object" && a.name == "config")
792 })
793 })
794 .collect();
795
796 for (idx, arg) in args.iter().enumerate() {
797 if arg.arg_type == "mock_url" {
798 setup_lines.push(format!(
799 "let {} = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"",
800 arg.name,
801 ));
802 parts.push(arg.name.clone());
803 continue;
804 }
805
806 if arg.arg_type == "handle" {
807 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
808 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
809 parts.push(var_name);
810 continue;
811 }
812
813 if arg.arg_type == "bytes" {
818 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
819 let val = input.get(field);
820 match val {
821 None | Some(serde_json::Value::Null) if arg.optional => {
822 if later_emits[idx] {
823 parts.push("nil".to_string());
824 }
825 }
826 None | Some(serde_json::Value::Null) => {
827 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
828 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
829 parts.push(var_name);
830 }
831 Some(serde_json::Value::String(s)) => {
832 let escaped = escape_swift(s);
833 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
834 let data_var = format!("{}Data", arg.name.to_lower_camel_case());
835 setup_lines.push(format!(
836 "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
837 ));
838 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
839 setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
840 parts.push(var_name);
841 }
842 Some(serde_json::Value::Array(arr)) => {
843 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
844 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
845 for v in arr {
846 if let Some(n) = v.as_u64() {
847 setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
848 }
849 }
850 parts.push(var_name);
851 }
852 Some(other) => {
853 let json_str = serde_json::to_string(other).unwrap_or_default();
855 let escaped = escape_swift(&json_str);
856 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
857 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
858 setup_lines.push(format!(
859 "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
860 ));
861 parts.push(var_name);
862 }
863 }
864 continue;
865 }
866
867 let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
872 let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
873 if is_config_arg && !is_batch_fn {
874 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
875 let val = input.get(field);
876 let json_str = match val {
877 None | Some(serde_json::Value::Null) => "{}".to_string(),
878 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
879 };
880 let escaped = escape_swift(&json_str);
881 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
882 setup_lines.push(format!("let {var_name} = try extractionConfigFromJson(\"{escaped}\")"));
883 parts.push(var_name);
884 continue;
885 }
886
887 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
888 let val = input.get(field);
889 match val {
890 None | Some(serde_json::Value::Null) if arg.optional => {
891 if later_emits[idx] {
895 parts.push("nil".to_string());
896 }
897 }
898 None | Some(serde_json::Value::Null) => {
899 let default_val = match arg.arg_type.as_str() {
900 "string" => "\"\"".to_string(),
901 "int" | "integer" => "0".to_string(),
902 "float" | "number" => "0.0".to_string(),
903 "bool" | "boolean" => "false".to_string(),
904 _ => "nil".to_string(),
905 };
906 parts.push(default_val);
907 }
908 Some(v) => {
909 parts.push(json_to_swift(v));
910 }
911 }
912 }
913
914 (setup_lines, parts.join(", "))
915}
916
917fn render_assertion(
918 out: &mut String,
919 assertion: &Assertion,
920 result_var: &str,
921 field_resolver: &FieldResolver,
922 result_is_simple: bool,
923 result_is_array: bool,
924 enum_fields: &HashSet<String>,
925) {
926 if let Some(f) = &assertion.field {
928 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
929 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
930 return;
931 }
932 }
933
934 if let Some(f) = &assertion.field {
939 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
940 let _ = writeln!(
941 out,
942 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
943 );
944 return;
945 }
946 }
947
948 let field_is_enum = assertion
950 .field
951 .as_deref()
952 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
953
954 let field_is_optional = assertion.field.as_deref().is_some_and(|f| {
955 !f.is_empty() && (field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f)))
956 });
957 let field_is_array = assertion.field.as_deref().is_some_and(|f| {
958 !f.is_empty() && (field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f)))
959 });
960
961 let field_expr = if result_is_simple {
962 result_var.to_string()
963 } else {
964 match &assertion.field {
965 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
966 _ => result_var.to_string(),
967 }
968 };
969
970 let accessor_is_optional = field_expr.contains("?.");
976
977 let string_expr = if field_is_enum {
986 field_expr.clone()
990 } else if field_is_optional {
991 format!("({field_expr}?.toString() ?? \"\")")
993 } else if accessor_is_optional {
994 format!("({field_expr}.toString() ?? \"\")")
997 } else {
998 format!("{field_expr}.toString()")
999 };
1000
1001 match assertion.assertion_type.as_str() {
1002 "equals" => {
1003 if let Some(expected) = &assertion.value {
1004 let swift_val = json_to_swift(expected);
1005 if expected.is_string() {
1006 if field_is_enum {
1007 let enum_str = format!("String(describing: {field_expr})");
1010 let trim_expr = format!("{enum_str}.trimmingCharacters(in: CharacterSet.whitespaces)");
1011 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1012 } else {
1013 let trim_expr = format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespaces)");
1018 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1019 }
1020 } else {
1021 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
1022 }
1023 }
1024 }
1025 "contains" => {
1026 if let Some(expected) = &assertion.value {
1027 let swift_val = json_to_swift(expected);
1028 let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
1031 if result_is_simple && result_is_array && no_field {
1032 let _ = writeln!(
1035 out,
1036 " XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1037 );
1038 } else {
1039 let field_is_array = assertion
1041 .field
1042 .as_deref()
1043 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1044 if field_is_array {
1045 let contains_expr =
1046 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1047 let _ = writeln!(
1048 out,
1049 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1050 );
1051 } else if field_is_enum {
1052 let enum_str = format!("String(describing: {field_expr})");
1054 let _ = writeln!(
1055 out,
1056 " XCTAssertTrue({enum_str}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1057 );
1058 } else {
1059 let _ = writeln!(
1060 out,
1061 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1062 );
1063 }
1064 }
1065 }
1066 }
1067 "contains_all" => {
1068 if let Some(values) = &assertion.values {
1069 let field_is_array = assertion
1071 .field
1072 .as_deref()
1073 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1074 if field_is_array {
1075 let contains_expr =
1076 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1077 for val in values {
1078 let swift_val = json_to_swift(val);
1079 let _ = writeln!(
1080 out,
1081 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1082 );
1083 }
1084 } else if field_is_enum {
1085 let enum_str = format!("String(describing: {field_expr})");
1087 for val in values {
1088 let swift_val = json_to_swift(val);
1089 let _ = writeln!(
1090 out,
1091 " XCTAssertTrue({enum_str}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1092 );
1093 }
1094 } else {
1095 for val in values {
1096 let swift_val = json_to_swift(val);
1097 let _ = writeln!(
1098 out,
1099 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1100 );
1101 }
1102 }
1103 }
1104 }
1105 "not_contains" => {
1106 if let Some(expected) = &assertion.value {
1107 let swift_val = json_to_swift(expected);
1108 let _ = writeln!(
1109 out,
1110 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
1111 );
1112 }
1113 }
1114 "not_empty" => {
1115 if field_is_optional {
1121 let _ = writeln!(out, " XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
1122 } else if field_is_array {
1123 let _ = writeln!(
1124 out,
1125 " XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
1126 );
1127 } else if result_is_simple {
1128 let _ = writeln!(
1130 out,
1131 " XCTAssertFalse({result_var}.isEmpty, \"expected non-empty value\")"
1132 );
1133 } else {
1134 let _ = writeln!(
1136 out,
1137 " XCTAssertFalse({string_expr}.isEmpty, \"expected non-empty value\")"
1138 );
1139 }
1140 }
1141 "is_empty" => {
1142 if field_is_optional {
1143 let _ = writeln!(out, " XCTAssertNil({field_expr}, \"expected nil value\")");
1144 } else if field_is_array {
1145 let _ = writeln!(
1146 out,
1147 " XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
1148 );
1149 } else {
1150 let _ = writeln!(
1151 out,
1152 " XCTAssertTrue({string_expr}.isEmpty, \"expected empty value\")"
1153 );
1154 }
1155 }
1156 "contains_any" => {
1157 if let Some(values) = &assertion.values {
1158 let checks: Vec<String> = values
1159 .iter()
1160 .map(|v| {
1161 let swift_val = json_to_swift(v);
1162 format!("{string_expr}.contains({swift_val})")
1163 })
1164 .collect();
1165 let joined = checks.join(" || ");
1166 let _ = writeln!(
1167 out,
1168 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
1169 );
1170 }
1171 }
1172 "greater_than" => {
1173 if let Some(val) = &assertion.value {
1174 let swift_val = json_to_swift(val);
1175 let field_is_optional = accessor_is_optional
1178 || assertion.field.as_deref().is_some_and(|f| {
1179 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1180 });
1181 let compare_expr = if field_is_optional {
1182 format!("({field_expr} ?? 0)")
1183 } else {
1184 field_expr.clone()
1185 };
1186 let _ = writeln!(out, " XCTAssertGreaterThan({compare_expr}, {swift_val})");
1187 }
1188 }
1189 "less_than" => {
1190 if let Some(val) = &assertion.value {
1191 let swift_val = json_to_swift(val);
1192 let field_is_optional = accessor_is_optional
1193 || assertion.field.as_deref().is_some_and(|f| {
1194 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1195 });
1196 let compare_expr = if field_is_optional {
1197 format!("({field_expr} ?? 0)")
1198 } else {
1199 field_expr.clone()
1200 };
1201 let _ = writeln!(out, " XCTAssertLessThan({compare_expr}, {swift_val})");
1202 }
1203 }
1204 "greater_than_or_equal" => {
1205 if let Some(val) = &assertion.value {
1206 let swift_val = json_to_swift(val);
1207 let field_is_optional = accessor_is_optional
1210 || assertion.field.as_deref().is_some_and(|f| {
1211 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1212 });
1213 let compare_expr = if field_is_optional {
1214 format!("({field_expr} ?? 0)")
1215 } else {
1216 field_expr.clone()
1217 };
1218 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
1219 }
1220 }
1221 "less_than_or_equal" => {
1222 if let Some(val) = &assertion.value {
1223 let swift_val = json_to_swift(val);
1224 let field_is_optional = accessor_is_optional
1225 || assertion.field.as_deref().is_some_and(|f| {
1226 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1227 });
1228 let compare_expr = if field_is_optional {
1229 format!("({field_expr} ?? 0)")
1230 } else {
1231 field_expr.clone()
1232 };
1233 let _ = writeln!(out, " XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
1234 }
1235 }
1236 "starts_with" => {
1237 if let Some(expected) = &assertion.value {
1238 let swift_val = json_to_swift(expected);
1239 let _ = writeln!(
1240 out,
1241 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1242 );
1243 }
1244 }
1245 "ends_with" => {
1246 if let Some(expected) = &assertion.value {
1247 let swift_val = json_to_swift(expected);
1248 let _ = writeln!(
1249 out,
1250 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1251 );
1252 }
1253 }
1254 "min_length" => {
1255 if let Some(val) = &assertion.value {
1256 if let Some(n) = val.as_u64() {
1257 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1260 }
1261 }
1262 }
1263 "max_length" => {
1264 if let Some(val) = &assertion.value {
1265 if let Some(n) = val.as_u64() {
1266 let _ = writeln!(out, " XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1267 }
1268 }
1269 }
1270 "count_min" => {
1271 if let Some(val) = &assertion.value {
1272 if let Some(n) = val.as_u64() {
1273 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1277 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1278 }
1279 }
1280 }
1281 "count_equals" => {
1282 if let Some(val) = &assertion.value {
1283 if let Some(n) = val.as_u64() {
1284 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1285 let _ = writeln!(out, " XCTAssertEqual({count_expr}, {n})");
1286 }
1287 }
1288 }
1289 "is_true" => {
1290 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
1291 }
1292 "is_false" => {
1293 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
1294 }
1295 "matches_regex" => {
1296 if let Some(expected) = &assertion.value {
1297 let swift_val = json_to_swift(expected);
1298 let _ = writeln!(
1299 out,
1300 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1301 );
1302 }
1303 }
1304 "not_error" => {
1305 }
1307 "error" => {
1308 }
1310 "method_result" => {
1311 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
1312 }
1313 other => {
1314 panic!("Swift e2e generator: unsupported assertion type: {other}");
1315 }
1316 }
1317}
1318
1319fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1328 let resolved = field_resolver.resolve(field);
1329 let parts: Vec<&str> = resolved.split('.').collect();
1330
1331 let mut out = result_var.to_string();
1334 let mut has_optional = false;
1335 let mut path_so_far = String::new();
1336 let total = parts.len();
1337 for (i, part) in parts.iter().enumerate() {
1338 let is_leaf = i == total - 1;
1339 let (field_name, subscript): (&str, Option<&str>) = if let Some(bracket_pos) = part.find('[') {
1343 (&part[..bracket_pos], Some(&part[bracket_pos..]))
1344 } else {
1345 (part, None)
1346 };
1347
1348 if !path_so_far.is_empty() {
1349 path_so_far.push('.');
1350 }
1351 path_so_far.push_str(part);
1352 out.push('.');
1353 out.push_str(field_name);
1354 out.push_str("()");
1355 if let Some(sub) = subscript {
1356 out.push_str(sub);
1357 }
1358 if !is_leaf && field_resolver.is_optional(&path_so_far) {
1361 out.push('?');
1362 has_optional = true;
1363 }
1364 }
1365 (out, has_optional)
1366}
1367
1368fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1384 let Some(f) = field else {
1385 return format!("{result_var}.map {{ $0.as_str().toString() }}");
1386 };
1387 let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
1388 format!("{accessor}?.map {{ $0.as_str().toString() }}")
1391}
1392
1393fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1402 let Some(f) = field else {
1403 return format!("{result_var}.count");
1404 };
1405 let (accessor, mut has_optional) = swift_build_accessor(f, result_var, field_resolver);
1406 if field_resolver.is_optional(f) {
1408 has_optional = true;
1409 }
1410 if has_optional {
1411 if accessor.contains("?.") {
1414 format!("{accessor}.count ?? 0")
1415 } else {
1416 format!("({accessor}?.count ?? 0)")
1419 }
1420 } else {
1421 format!("{accessor}.count")
1422 }
1423}
1424
1425fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
1431 let mut components = std::path::PathBuf::new();
1432 for component in path.components() {
1433 match component {
1434 std::path::Component::ParentDir => {
1435 if !components.as_os_str().is_empty() {
1438 components.pop();
1439 } else {
1440 components.push(component);
1441 }
1442 }
1443 std::path::Component::CurDir => {}
1444 other => components.push(other),
1445 }
1446 }
1447 components
1448}
1449
1450fn json_to_swift(value: &serde_json::Value) -> String {
1452 match value {
1453 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
1454 serde_json::Value::Bool(b) => b.to_string(),
1455 serde_json::Value::Number(n) => n.to_string(),
1456 serde_json::Value::Null => "nil".to_string(),
1457 serde_json::Value::Array(arr) => {
1458 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
1459 format!("[{}]", items.join(", "))
1460 }
1461 serde_json::Value::Object(_) => {
1462 let json_str = serde_json::to_string(value).unwrap_or_default();
1463 format!("\"{}\"", escape_swift(&json_str))
1464 }
1465 }
1466}
1467
1468fn escape_swift(s: &str) -> String {
1470 escape_swift_str(s)
1471}