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 for group in groups {
123 let active: Vec<&Fixture> = group
124 .fixtures
125 .iter()
126 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
127 .collect();
128
129 if active.is_empty() {
130 continue;
131 }
132
133 let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
134 let filename = format!("{class_name}.swift");
135 let content = render_test_file(
136 &group.category,
137 &active,
138 e2e_config,
139 module_name,
140 &class_name,
141 &function_name,
142 result_var,
143 &e2e_config.call.args,
144 &field_resolver,
145 result_is_simple,
146 &e2e_config.fields_enum,
147 );
148 files.push(GeneratedFile {
149 path: tests_base
150 .join("Tests")
151 .join(format!("{module_name}Tests"))
152 .join(filename),
153 content,
154 generated_header: true,
155 });
156 }
157
158 Ok(files)
159 }
160
161 fn language_name(&self) -> &'static str {
162 "swift"
163 }
164}
165
166fn render_package_swift(
171 module_name: &str,
172 registry_url: &str,
173 pkg_path: &str,
174 pkg_version: &str,
175 dep_mode: crate::config::DependencyMode,
176) -> String {
177 let min_macos = toolchain::SWIFT_MIN_MACOS;
178
179 let (dep_block, product_dep) = match dep_mode {
183 crate::config::DependencyMode::Registry => {
184 let dep = format!(r#" .package(url: "{registry_url}", from: "{pkg_version}")"#);
185 let pkg_id = registry_url
186 .trim_end_matches('/')
187 .trim_end_matches(".git")
188 .split('/')
189 .next_back()
190 .unwrap_or(module_name);
191 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
192 (dep, prod)
193 }
194 crate::config::DependencyMode::Local => {
195 let dep = format!(r#" .package(path: "{pkg_path}")"#);
196 let pkg_id = pkg_path
197 .trim_end_matches('/')
198 .split('/')
199 .next_back()
200 .unwrap_or(module_name);
201 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
202 (dep, prod)
203 }
204 };
205 let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
208 format!(
209 r#"// swift-tools-version: 6.0
210import PackageDescription
211
212let package = Package(
213 name: "E2eSwift",
214 platforms: [
215 .macOS(.v{min_macos_major}),
216 ],
217 dependencies: [
218{dep_block},
219 ],
220 targets: [
221 .testTarget(
222 name: "{module_name}Tests",
223 dependencies: [{product_dep}]
224 ),
225 ]
226)
227"#
228 )
229}
230
231#[allow(clippy::too_many_arguments)]
232fn render_test_file(
233 category: &str,
234 fixtures: &[&Fixture],
235 e2e_config: &E2eConfig,
236 module_name: &str,
237 class_name: &str,
238 function_name: &str,
239 result_var: &str,
240 args: &[crate::config::ArgMapping],
241 field_resolver: &FieldResolver,
242 result_is_simple: bool,
243 enum_fields: &HashSet<String>,
244) -> String {
245 let needs_chdir = fixtures.iter().any(|f| {
252 let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
253 call_config
254 .args
255 .iter()
256 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
257 });
258
259 let mut out = String::new();
260 out.push_str(&hash::header(CommentStyle::DoubleSlash));
261 let _ = writeln!(out, "import XCTest");
262 let _ = writeln!(out, "import Foundation");
263 let _ = writeln!(out, "import {module_name}");
264 let _ = writeln!(out, "import RustBridge");
265 let _ = writeln!(out);
266 let _ = writeln!(out, "/// E2e tests for category: {category}.");
267 let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
268
269 if needs_chdir {
270 let _ = writeln!(out, " override class func setUp() {{");
278 let _ = writeln!(out, " super.setUp()");
279 let _ = writeln!(out, " let _testDocs = URL(fileURLWithPath: #filePath)");
280 let _ = writeln!(out, " .deletingLastPathComponent() // <Module>Tests/");
281 let _ = writeln!(out, " .deletingLastPathComponent() // Tests/");
282 let _ = writeln!(out, " .deletingLastPathComponent() // swift/");
283 let _ = writeln!(out, " .deletingLastPathComponent() // packages/");
284 let _ = writeln!(out, " .deletingLastPathComponent() // <repo root>");
285 let _ = writeln!(
286 out,
287 " .appendingPathComponent(\"{}\")",
288 e2e_config.test_documents_dir
289 );
290 let _ = writeln!(
291 out,
292 " if FileManager.default.fileExists(atPath: _testDocs.path) {{"
293 );
294 let _ = writeln!(
295 out,
296 " FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
297 );
298 let _ = writeln!(out, " }}");
299 let _ = writeln!(out, " }}");
300 let _ = writeln!(out);
301 }
302
303 for fixture in fixtures {
304 if fixture.is_http_test() {
305 render_http_test_method(&mut out, fixture);
306 } else {
307 render_test_method(
308 &mut out,
309 fixture,
310 e2e_config,
311 function_name,
312 result_var,
313 args,
314 field_resolver,
315 result_is_simple,
316 enum_fields,
317 );
318 }
319 let _ = writeln!(out);
320 }
321
322 let _ = writeln!(out, "}}");
323 out
324}
325
326struct SwiftTestClientRenderer;
333
334impl client::TestClientRenderer for SwiftTestClientRenderer {
335 fn language_name(&self) -> &'static str {
336 "swift"
337 }
338
339 fn sanitize_test_name(&self, id: &str) -> String {
340 sanitize_ident(id).to_upper_camel_case()
342 }
343
344 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
350 let _ = writeln!(out, " /// {description}");
351 let _ = writeln!(out, " func test{fn_name}() throws {{");
352 if let Some(reason) = skip_reason {
353 let escaped = escape_swift(reason);
354 let _ = writeln!(out, " try XCTSkipIf(true, \"{escaped}\")");
355 }
356 }
357
358 fn render_test_close(&self, out: &mut String) {
359 let _ = writeln!(out, " }}");
360 }
361
362 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
369 let method = ctx.method.to_uppercase();
370 let fixture_path = escape_swift(ctx.path);
371
372 let _ = writeln!(
373 out,
374 " let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
375 );
376 let _ = writeln!(
377 out,
378 " var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
379 );
380 let _ = writeln!(out, " _req.httpMethod = \"{method}\"");
381
382 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
384 header_pairs.sort_by_key(|(k, _)| k.as_str());
385 for (k, v) in &header_pairs {
386 let expanded_v = expand_fixture_templates(v);
387 let ek = escape_swift(k);
388 let ev = escape_swift(&expanded_v);
389 let _ = writeln!(out, " _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
390 }
391
392 if let Some(body) = ctx.body {
394 let json_str = serde_json::to_string(body).unwrap_or_default();
395 let escaped_body = escape_swift(&json_str);
396 let _ = writeln!(out, " _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
397 let _ = writeln!(
398 out,
399 " _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
400 );
401 }
402
403 let _ = writeln!(out, " var {}: HTTPURLResponse?", ctx.response_var);
404 let _ = writeln!(out, " var _responseData: Data?");
405 let _ = writeln!(out, " let _sema = DispatchSemaphore(value: 0)");
406 let _ = writeln!(
407 out,
408 " URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
409 );
410 let _ = writeln!(out, " {} = resp as? HTTPURLResponse", ctx.response_var);
411 let _ = writeln!(out, " _responseData = data");
412 let _ = writeln!(out, " _sema.signal()");
413 let _ = writeln!(out, " }}.resume()");
414 let _ = writeln!(out, " _sema.wait()");
415 let _ = writeln!(out, " let _resp = try XCTUnwrap({})", ctx.response_var);
416 }
417
418 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
419 let _ = writeln!(out, " XCTAssertEqual(_resp.statusCode, {status})");
420 }
421
422 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
423 let lower_name = name.to_lowercase();
424 let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
425 match expected {
426 "<<present>>" => {
427 let _ = writeln!(out, " XCTAssertNotNil({header_expr})");
428 }
429 "<<absent>>" => {
430 let _ = writeln!(out, " XCTAssertNil({header_expr})");
431 }
432 "<<uuid>>" => {
433 let _ = writeln!(out, " let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
434 let _ = writeln!(
435 out,
436 " 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))"
437 );
438 }
439 exact => {
440 let escaped = escape_swift(exact);
441 let _ = writeln!(out, " XCTAssertEqual({header_expr}, \"{escaped}\")");
442 }
443 }
444 }
445
446 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
447 if let serde_json::Value::String(s) = expected {
448 let escaped = escape_swift(s);
449 let _ = writeln!(
450 out,
451 " let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
452 );
453 let _ = writeln!(
454 out,
455 " XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
456 );
457 } else {
458 let json_str = serde_json::to_string(expected).unwrap_or_default();
459 let escaped = escape_swift(&json_str);
460 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
461 let _ = writeln!(
462 out,
463 " let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
464 );
465 let _ = writeln!(
466 out,
467 " let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
468 );
469 let _ = writeln!(
470 out,
471 " XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
472 );
473 }
474 }
475
476 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
477 if let Some(obj) = expected.as_object() {
478 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
479 let _ = writeln!(
480 out,
481 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
482 );
483 for (key, val) in obj {
484 let escaped_key = escape_swift(key);
485 let swift_val = json_to_swift(val);
486 let _ = writeln!(
487 out,
488 " XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
489 );
490 }
491 }
492 }
493
494 fn render_assert_validation_errors(
495 &self,
496 out: &mut String,
497 _response_var: &str,
498 errors: &[ValidationErrorExpectation],
499 ) {
500 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
501 let _ = writeln!(
502 out,
503 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
504 );
505 let _ = writeln!(
506 out,
507 " let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
508 );
509 for ve in errors {
510 let escaped_msg = escape_swift(&ve.msg);
511 let _ = writeln!(
512 out,
513 " XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
514 );
515 }
516 }
517}
518
519fn render_http_test_method(out: &mut String, fixture: &Fixture) {
524 let Some(http) = &fixture.http else {
525 return;
526 };
527
528 if http.expected_response.status_code == 101 {
530 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
531 let description = fixture.description.replace('"', "\\\"");
532 let _ = writeln!(out, " /// {description}");
533 let _ = writeln!(out, " func test{method_name}() throws {{");
534 let _ = writeln!(
535 out,
536 " try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
537 );
538 let _ = writeln!(out, " }}");
539 return;
540 }
541
542 client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
543}
544
545#[allow(clippy::too_many_arguments)]
550fn render_test_method(
551 out: &mut String,
552 fixture: &Fixture,
553 e2e_config: &E2eConfig,
554 _function_name: &str,
555 _result_var: &str,
556 _args: &[crate::config::ArgMapping],
557 field_resolver: &FieldResolver,
558 result_is_simple: bool,
559 enum_fields: &HashSet<String>,
560) {
561 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
563 let lang = "swift";
564 let call_overrides = call_config.overrides.get(lang);
565 let function_name = call_overrides
566 .and_then(|o| o.function.as_ref())
567 .cloned()
568 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
569 let result_var = &call_config.result_var;
570 let args = &call_config.args;
571 let result_is_simple = call_config.result_is_simple || result_is_simple;
573 let result_is_array = call_config.result_is_array;
574
575 let method_name = fixture.id.to_upper_camel_case();
576 let description = &fixture.description;
577 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
578 let is_async = call_config.r#async;
579
580 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id, &function_name);
581
582 let qualified_function_name = function_name.clone();
586
587 if is_async {
588 let _ = writeln!(out, " func test{method_name}() async throws {{");
589 } else {
590 let _ = writeln!(out, " func test{method_name}() throws {{");
591 }
592 let _ = writeln!(out, " // {description}");
593
594 for line in &setup_lines {
595 let _ = writeln!(out, " {line}");
596 }
597
598 if expects_error {
599 if is_async {
600 let _ = writeln!(out, " do {{");
605 let _ = writeln!(out, " _ = try await {qualified_function_name}({args_str})");
606 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
607 let _ = writeln!(out, " }} catch {{");
608 let _ = writeln!(out, " // success");
609 let _ = writeln!(out, " }}");
610 } else {
611 let _ = writeln!(
612 out,
613 " XCTAssertThrowsError(try {qualified_function_name}({args_str}))"
614 );
615 }
616 let _ = writeln!(out, " }}");
617 return;
618 }
619
620 if is_async {
621 let _ = writeln!(
622 out,
623 " let {result_var} = try await {qualified_function_name}({args_str})"
624 );
625 } else {
626 let _ = writeln!(
627 out,
628 " let {result_var} = try {qualified_function_name}({args_str})"
629 );
630 }
631
632 for assertion in &fixture.assertions {
633 render_assertion(
634 out,
635 assertion,
636 result_var,
637 field_resolver,
638 result_is_simple,
639 result_is_array,
640 enum_fields,
641 );
642 }
643
644 let _ = writeln!(out, " }}");
645}
646
647fn build_args_and_setup(
661 input: &serde_json::Value,
662 args: &[crate::config::ArgMapping],
663 fixture_id: &str,
664 function_name: &str,
665) -> (Vec<String>, String) {
666 if args.is_empty() {
667 return (Vec::new(), String::new());
668 }
669
670 let mut setup_lines: Vec<String> = Vec::new();
671 let mut parts: Vec<String> = Vec::new();
672
673 let later_emits: Vec<bool> = (0..args.len())
678 .map(|i| {
679 args.iter().skip(i + 1).any(|a| {
680 let f = a.field.strip_prefix("input.").unwrap_or(&a.field);
681 let v = input.get(f);
682 let has_value = matches!(v, Some(x) if !x.is_null());
683 has_value || !a.optional || (a.arg_type == "json_object" && a.name == "config")
684 })
685 })
686 .collect();
687
688 for (idx, arg) in args.iter().enumerate() {
689 if arg.arg_type == "mock_url" {
690 setup_lines.push(format!(
691 "let {} = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"",
692 arg.name,
693 ));
694 parts.push(arg.name.clone());
695 continue;
696 }
697
698 if arg.arg_type == "bytes" {
703 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
704 let val = input.get(field);
705 match val {
706 None | Some(serde_json::Value::Null) if arg.optional => {
707 if later_emits[idx] {
708 parts.push("nil".to_string());
709 }
710 }
711 None | Some(serde_json::Value::Null) => {
712 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
713 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
714 parts.push(var_name);
715 }
716 Some(serde_json::Value::String(s)) => {
717 let escaped = escape_swift(s);
718 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
719 let data_var = format!("{}Data", arg.name.to_lower_camel_case());
720 setup_lines.push(format!(
721 "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
722 ));
723 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
724 setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
725 parts.push(var_name);
726 }
727 Some(serde_json::Value::Array(arr)) => {
728 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
729 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
730 for v in arr {
731 if let Some(n) = v.as_u64() {
732 setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
733 }
734 }
735 parts.push(var_name);
736 }
737 Some(other) => {
738 let json_str = serde_json::to_string(other).unwrap_or_default();
740 let escaped = escape_swift(&json_str);
741 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
742 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
743 setup_lines.push(format!(
744 "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
745 ));
746 parts.push(var_name);
747 }
748 }
749 continue;
750 }
751
752 let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
757 let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
758 if is_config_arg && !is_batch_fn {
759 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
760 let val = input.get(field);
761 let json_str = match val {
762 None | Some(serde_json::Value::Null) => "{}".to_string(),
763 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
764 };
765 let escaped = escape_swift(&json_str);
766 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
767 setup_lines.push(format!("let {var_name} = try extractionConfigFromJson(\"{escaped}\")"));
768 parts.push(var_name);
769 continue;
770 }
771
772 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
773 let val = input.get(field);
774 match val {
775 None | Some(serde_json::Value::Null) if arg.optional => {
776 if later_emits[idx] {
780 parts.push("nil".to_string());
781 }
782 }
783 None | Some(serde_json::Value::Null) => {
784 let default_val = match arg.arg_type.as_str() {
785 "string" => "\"\"".to_string(),
786 "int" | "integer" => "0".to_string(),
787 "float" | "number" => "0.0".to_string(),
788 "bool" | "boolean" => "false".to_string(),
789 _ => "nil".to_string(),
790 };
791 parts.push(default_val);
792 }
793 Some(v) => {
794 parts.push(json_to_swift(v));
795 }
796 }
797 }
798
799 (setup_lines, parts.join(", "))
800}
801
802fn render_assertion(
803 out: &mut String,
804 assertion: &Assertion,
805 result_var: &str,
806 field_resolver: &FieldResolver,
807 result_is_simple: bool,
808 result_is_array: bool,
809 enum_fields: &HashSet<String>,
810) {
811 if let Some(f) = &assertion.field {
813 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
814 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
815 return;
816 }
817 }
818
819 if let Some(f) = &assertion.field {
824 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
825 let _ = writeln!(
826 out,
827 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
828 );
829 return;
830 }
831 }
832
833 let field_is_enum = assertion
835 .field
836 .as_deref()
837 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
838
839 let field_expr = if result_is_simple {
840 result_var.to_string()
841 } else {
842 match &assertion.field {
843 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
844 _ => result_var.to_string(),
845 }
846 };
847
848 let string_expr = if field_is_enum {
854 format!("{field_expr}.rawValue")
855 } else {
856 format!("{field_expr}.toString()")
857 };
858
859 match assertion.assertion_type.as_str() {
860 "equals" => {
861 if let Some(expected) = &assertion.value {
862 let swift_val = json_to_swift(expected);
863 if expected.is_string() {
864 let field_is_optional = assertion
868 .field
869 .as_deref()
870 .is_some_and(|f| field_resolver.is_optional(f));
871 let trim_expr = if field_is_optional {
872 format!("(({field_expr})?.toString() ?? \"\").trimmingCharacters(in: .whitespaces)")
873 } else {
874 format!("{string_expr}.trimmingCharacters(in: .whitespaces)")
876 };
877 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
878 } else {
879 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
880 }
881 }
882 }
883 "contains" => {
884 if let Some(expected) = &assertion.value {
885 let swift_val = json_to_swift(expected);
886 let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
889 if result_is_simple && result_is_array && no_field {
890 let _ = writeln!(
893 out,
894 " XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
895 );
896 } else {
897 let field_is_array = assertion
899 .field
900 .as_deref()
901 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
902 if field_is_array {
903 let contains_expr =
904 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
905 let _ = writeln!(
906 out,
907 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
908 );
909 } else {
910 let _ = writeln!(
911 out,
912 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
913 );
914 }
915 }
916 }
917 }
918 "contains_all" => {
919 if let Some(values) = &assertion.values {
920 let field_is_array = assertion
922 .field
923 .as_deref()
924 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
925 if field_is_array {
926 let contains_expr =
927 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
928 for val in values {
929 let swift_val = json_to_swift(val);
930 let _ = writeln!(
931 out,
932 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
933 );
934 }
935 } else {
936 for val in values {
937 let swift_val = json_to_swift(val);
938 let _ = writeln!(
939 out,
940 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
941 );
942 }
943 }
944 }
945 }
946 "not_contains" => {
947 if let Some(expected) = &assertion.value {
948 let swift_val = json_to_swift(expected);
949 let _ = writeln!(
950 out,
951 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
952 );
953 }
954 }
955 "not_empty" => {
956 let field_is_optional = assertion
959 .field
960 .as_deref()
961 .is_some_and(|f| field_resolver.is_optional(f));
962 if field_is_optional {
963 let _ = writeln!(out, " XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
964 } else {
965 let _ = writeln!(
967 out,
968 " XCTAssertFalse({string_expr}.isEmpty, \"expected non-empty value\")"
969 );
970 }
971 }
972 "is_empty" => {
973 let field_is_optional = assertion
974 .field
975 .as_deref()
976 .is_some_and(|f| field_resolver.is_optional(f));
977 if field_is_optional {
978 let _ = writeln!(out, " XCTAssertNil({field_expr}, \"expected nil value\")");
979 } else {
980 let _ = writeln!(
981 out,
982 " XCTAssertTrue({string_expr}.isEmpty, \"expected empty value\")"
983 );
984 }
985 }
986 "contains_any" => {
987 if let Some(values) = &assertion.values {
988 let checks: Vec<String> = values
989 .iter()
990 .map(|v| {
991 let swift_val = json_to_swift(v);
992 format!("{string_expr}.contains({swift_val})")
993 })
994 .collect();
995 let joined = checks.join(" || ");
996 let _ = writeln!(
997 out,
998 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
999 );
1000 }
1001 }
1002 "greater_than" => {
1003 if let Some(val) = &assertion.value {
1004 let swift_val = json_to_swift(val);
1005 let field_is_optional = assertion
1007 .field
1008 .as_deref()
1009 .is_some_and(|f| field_resolver.is_optional(f));
1010 let compare_expr = if field_is_optional {
1011 format!("({field_expr} ?? 0)")
1012 } else {
1013 field_expr.clone()
1014 };
1015 let _ = writeln!(out, " XCTAssertGreaterThan({compare_expr}, {swift_val})");
1016 }
1017 }
1018 "less_than" => {
1019 if let Some(val) = &assertion.value {
1020 let swift_val = json_to_swift(val);
1021 let field_is_optional = assertion
1022 .field
1023 .as_deref()
1024 .is_some_and(|f| field_resolver.is_optional(f));
1025 let compare_expr = if field_is_optional {
1026 format!("({field_expr} ?? 0)")
1027 } else {
1028 field_expr.clone()
1029 };
1030 let _ = writeln!(out, " XCTAssertLessThan({compare_expr}, {swift_val})");
1031 }
1032 }
1033 "greater_than_or_equal" => {
1034 if let Some(val) = &assertion.value {
1035 let swift_val = json_to_swift(val);
1036 let field_is_optional = assertion
1038 .field
1039 .as_deref()
1040 .is_some_and(|f| field_resolver.is_optional(f));
1041 let compare_expr = if field_is_optional {
1042 format!("({field_expr} ?? 0)")
1043 } else {
1044 field_expr.clone()
1045 };
1046 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
1047 }
1048 }
1049 "less_than_or_equal" => {
1050 if let Some(val) = &assertion.value {
1051 let swift_val = json_to_swift(val);
1052 let field_is_optional = assertion
1053 .field
1054 .as_deref()
1055 .is_some_and(|f| field_resolver.is_optional(f));
1056 let compare_expr = if field_is_optional {
1057 format!("({field_expr} ?? 0)")
1058 } else {
1059 field_expr.clone()
1060 };
1061 let _ = writeln!(out, " XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
1062 }
1063 }
1064 "starts_with" => {
1065 if let Some(expected) = &assertion.value {
1066 let swift_val = json_to_swift(expected);
1067 let _ = writeln!(
1068 out,
1069 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1070 );
1071 }
1072 }
1073 "ends_with" => {
1074 if let Some(expected) = &assertion.value {
1075 let swift_val = json_to_swift(expected);
1076 let _ = writeln!(
1077 out,
1078 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1079 );
1080 }
1081 }
1082 "min_length" => {
1083 if let Some(val) = &assertion.value {
1084 if let Some(n) = val.as_u64() {
1085 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1088 }
1089 }
1090 }
1091 "max_length" => {
1092 if let Some(val) = &assertion.value {
1093 if let Some(n) = val.as_u64() {
1094 let _ = writeln!(out, " XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1095 }
1096 }
1097 }
1098 "count_min" => {
1099 if let Some(val) = &assertion.value {
1100 if let Some(n) = val.as_u64() {
1101 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1105 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1106 }
1107 }
1108 }
1109 "count_equals" => {
1110 if let Some(val) = &assertion.value {
1111 if let Some(n) = val.as_u64() {
1112 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1113 let _ = writeln!(out, " XCTAssertEqual({count_expr}, {n})");
1114 }
1115 }
1116 }
1117 "is_true" => {
1118 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
1119 }
1120 "is_false" => {
1121 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
1122 }
1123 "matches_regex" => {
1124 if let Some(expected) = &assertion.value {
1125 let swift_val = json_to_swift(expected);
1126 let _ = writeln!(
1127 out,
1128 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1129 );
1130 }
1131 }
1132 "not_error" => {
1133 }
1135 "error" => {
1136 }
1138 "method_result" => {
1139 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
1140 }
1141 other => {
1142 panic!("Swift e2e generator: unsupported assertion type: {other}");
1143 }
1144 }
1145}
1146
1147fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1156 let resolved = field_resolver.resolve(field);
1157 let parts: Vec<&str> = resolved.split('.').collect();
1158
1159 let mut out = result_var.to_string();
1162 let mut has_optional = false;
1163 let mut path_so_far = String::new();
1164 let total = parts.len();
1165 for (i, part) in parts.iter().enumerate() {
1166 let is_leaf = i == total - 1;
1167 if !path_so_far.is_empty() {
1168 path_so_far.push('.');
1169 }
1170 path_so_far.push_str(part);
1171 out.push('.');
1172 out.push_str(part);
1173 out.push_str("()");
1174 if !is_leaf && field_resolver.is_optional(&path_so_far) {
1177 out.push('?');
1178 has_optional = true;
1179 }
1180 }
1181 (out, has_optional)
1182}
1183
1184fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1200 let Some(f) = field else {
1201 return format!("{result_var}.map {{ $0.as_str().toString() }}");
1202 };
1203 let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
1204 format!("{accessor}?.map {{ $0.as_str().toString() }}")
1207}
1208
1209fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1215 let Some(f) = field else {
1216 return format!("{result_var}.count");
1217 };
1218 let (accessor, has_optional) = swift_build_accessor(f, result_var, field_resolver);
1219 if has_optional {
1220 format!("{accessor}.count ?? 0")
1221 } else {
1222 format!("{accessor}.count")
1223 }
1224}
1225
1226fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
1232 let mut components = std::path::PathBuf::new();
1233 for component in path.components() {
1234 match component {
1235 std::path::Component::ParentDir => {
1236 if !components.as_os_str().is_empty() {
1239 components.pop();
1240 } else {
1241 components.push(component);
1242 }
1243 }
1244 std::path::Component::CurDir => {}
1245 other => components.push(other),
1246 }
1247 }
1248 components
1249}
1250
1251fn json_to_swift(value: &serde_json::Value) -> String {
1253 match value {
1254 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
1255 serde_json::Value::Bool(b) => b.to_string(),
1256 serde_json::Value::Number(n) => n.to_string(),
1257 serde_json::Value::Null => "nil".to_string(),
1258 serde_json::Value::Array(arr) => {
1259 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
1260 format!("[{}]", items.join(", "))
1261 }
1262 serde_json::Value::Object(_) => {
1263 let json_str = serde_json::to_string(value).unwrap_or_default();
1264 format!("\"{}\"", escape_swift(&json_str))
1265 }
1266 }
1267}
1268
1269fn escape_swift(s: &str) -> String {
1271 escape_swift_str(s)
1272}