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 ) -> Result<Vec<GeneratedFile>> {
41 let lang = self.language_name();
42 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
43
44 let mut files = Vec::new();
45
46 let call = &e2e_config.call;
48 let overrides = call.overrides.get(lang);
49 let function_name = overrides
50 .and_then(|o| o.function.as_ref())
51 .cloned()
52 .unwrap_or_else(|| call.function.clone());
53 let result_var = &call.result_var;
54 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
55
56 let swift_pkg = e2e_config.resolve_package("swift");
58 let pkg_name = swift_pkg
59 .as_ref()
60 .and_then(|p| p.name.as_ref())
61 .cloned()
62 .unwrap_or_else(|| config.name.to_upper_camel_case());
63 let pkg_path = swift_pkg
64 .as_ref()
65 .and_then(|p| p.path.as_ref())
66 .cloned()
67 .unwrap_or_else(|| "../../packages/swift".to_string());
68 let pkg_version = swift_pkg
69 .as_ref()
70 .and_then(|p| p.version.as_ref())
71 .cloned()
72 .or_else(|| config.resolved_version())
73 .unwrap_or_else(|| "0.1.0".to_string());
74
75 let module_name = pkg_name.as_str();
77
78 let registry_url = config
82 .try_github_repo()
83 .map(|repo| {
84 let base = repo.trim_end_matches('/').trim_end_matches(".git");
85 format!("{base}.git")
86 })
87 .unwrap_or_else(|_| format!("https://example.invalid/{module_name}.git"));
88
89 files.push(GeneratedFile {
92 path: output_base.join("Package.swift"),
93 content: render_package_swift(module_name, ®istry_url, &pkg_path, &pkg_version, e2e_config.dep_mode),
94 generated_header: false,
95 });
96
97 let tests_base = normalize_path(&output_base.join(&pkg_path));
111
112 let field_resolver = FieldResolver::new(
113 &e2e_config.fields,
114 &e2e_config.fields_optional,
115 &e2e_config.result_fields,
116 &e2e_config.fields_array,
117 &e2e_config.fields_method_calls,
118 );
119
120 for group in groups {
122 let active: Vec<&Fixture> = group
123 .fixtures
124 .iter()
125 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
126 .collect();
127
128 if active.is_empty() {
129 continue;
130 }
131
132 let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
133 let filename = format!("{class_name}.swift");
134 let content = render_test_file(
135 &group.category,
136 &active,
137 e2e_config,
138 module_name,
139 &class_name,
140 &function_name,
141 result_var,
142 &e2e_config.call.args,
143 &field_resolver,
144 result_is_simple,
145 &e2e_config.fields_enum,
146 );
147 files.push(GeneratedFile {
148 path: tests_base
149 .join("Tests")
150 .join(format!("{module_name}Tests"))
151 .join(filename),
152 content,
153 generated_header: true,
154 });
155 }
156
157 Ok(files)
158 }
159
160 fn language_name(&self) -> &'static str {
161 "swift"
162 }
163}
164
165fn render_package_swift(
170 module_name: &str,
171 registry_url: &str,
172 pkg_path: &str,
173 pkg_version: &str,
174 dep_mode: crate::config::DependencyMode,
175) -> String {
176 let min_macos = toolchain::SWIFT_MIN_MACOS;
177
178 let (dep_block, product_dep) = match dep_mode {
182 crate::config::DependencyMode::Registry => {
183 let dep = format!(r#" .package(url: "{registry_url}", from: "{pkg_version}")"#);
184 let pkg_id = registry_url
185 .trim_end_matches('/')
186 .trim_end_matches(".git")
187 .split('/')
188 .next_back()
189 .unwrap_or(module_name);
190 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
191 (dep, prod)
192 }
193 crate::config::DependencyMode::Local => {
194 let dep = format!(r#" .package(path: "{pkg_path}")"#);
195 let pkg_id = pkg_path
196 .trim_end_matches('/')
197 .split('/')
198 .next_back()
199 .unwrap_or(module_name);
200 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
201 (dep, prod)
202 }
203 };
204 let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
207 format!(
208 r#"// swift-tools-version: 6.0
209import PackageDescription
210
211let package = Package(
212 name: "E2eSwift",
213 platforms: [
214 .macOS(.v{min_macos_major}),
215 ],
216 dependencies: [
217{dep_block},
218 ],
219 targets: [
220 .testTarget(
221 name: "{module_name}Tests",
222 dependencies: [{product_dep}]
223 ),
224 ]
225)
226"#
227 )
228}
229
230#[allow(clippy::too_many_arguments)]
231fn render_test_file(
232 category: &str,
233 fixtures: &[&Fixture],
234 e2e_config: &E2eConfig,
235 module_name: &str,
236 class_name: &str,
237 function_name: &str,
238 result_var: &str,
239 args: &[crate::config::ArgMapping],
240 field_resolver: &FieldResolver,
241 result_is_simple: bool,
242 enum_fields: &HashSet<String>,
243) -> String {
244 let needs_chdir = fixtures.iter().any(|f| {
251 let call_config = e2e_config.resolve_call(f.call.as_deref());
252 call_config
253 .args
254 .iter()
255 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
256 });
257
258 let mut out = String::new();
259 out.push_str(&hash::header(CommentStyle::DoubleSlash));
260 let _ = writeln!(out, "import XCTest");
261 let _ = writeln!(out, "import Foundation");
262 let _ = writeln!(out, "import {module_name}");
263 let _ = writeln!(out, "import RustBridge");
264 let _ = writeln!(out);
265 let _ = writeln!(out, "/// E2e tests for category: {category}.");
266 let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
267
268 if needs_chdir {
269 let _ = writeln!(out, " override class func setUp() {{");
277 let _ = writeln!(out, " super.setUp()");
278 let _ = writeln!(out, " let _testDocs = URL(fileURLWithPath: #filePath)");
279 let _ = writeln!(out, " .deletingLastPathComponent() // <Module>Tests/");
280 let _ = writeln!(out, " .deletingLastPathComponent() // Tests/");
281 let _ = writeln!(out, " .deletingLastPathComponent() // swift/");
282 let _ = writeln!(out, " .deletingLastPathComponent() // packages/");
283 let _ = writeln!(out, " .deletingLastPathComponent() // <repo root>");
284 let _ = writeln!(out, " .appendingPathComponent(\"test_documents\")");
285 let _ = writeln!(
286 out,
287 " if FileManager.default.fileExists(atPath: _testDocs.path) {{"
288 );
289 let _ = writeln!(
290 out,
291 " FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
292 );
293 let _ = writeln!(out, " }}");
294 let _ = writeln!(out, " }}");
295 let _ = writeln!(out);
296 }
297
298 for fixture in fixtures {
299 if fixture.is_http_test() {
300 render_http_test_method(&mut out, fixture);
301 } else {
302 render_test_method(
303 &mut out,
304 fixture,
305 e2e_config,
306 function_name,
307 result_var,
308 args,
309 field_resolver,
310 result_is_simple,
311 enum_fields,
312 );
313 }
314 let _ = writeln!(out);
315 }
316
317 let _ = writeln!(out, "}}");
318 out
319}
320
321struct SwiftTestClientRenderer;
328
329impl client::TestClientRenderer for SwiftTestClientRenderer {
330 fn language_name(&self) -> &'static str {
331 "swift"
332 }
333
334 fn sanitize_test_name(&self, id: &str) -> String {
335 sanitize_ident(id).to_upper_camel_case()
337 }
338
339 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
345 let _ = writeln!(out, " /// {description}");
346 let _ = writeln!(out, " func test{fn_name}() throws {{");
347 if let Some(reason) = skip_reason {
348 let escaped = escape_swift(reason);
349 let _ = writeln!(out, " try XCTSkipIf(true, \"{escaped}\")");
350 }
351 }
352
353 fn render_test_close(&self, out: &mut String) {
354 let _ = writeln!(out, " }}");
355 }
356
357 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
364 let method = ctx.method.to_uppercase();
365 let fixture_path = escape_swift(ctx.path);
366
367 let _ = writeln!(
368 out,
369 " let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
370 );
371 let _ = writeln!(
372 out,
373 " var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
374 );
375 let _ = writeln!(out, " _req.httpMethod = \"{method}\"");
376
377 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
379 header_pairs.sort_by_key(|(k, _)| k.as_str());
380 for (k, v) in &header_pairs {
381 let expanded_v = expand_fixture_templates(v);
382 let ek = escape_swift(k);
383 let ev = escape_swift(&expanded_v);
384 let _ = writeln!(out, " _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
385 }
386
387 if let Some(body) = ctx.body {
389 let json_str = serde_json::to_string(body).unwrap_or_default();
390 let escaped_body = escape_swift(&json_str);
391 let _ = writeln!(out, " _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
392 let _ = writeln!(
393 out,
394 " _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
395 );
396 }
397
398 let _ = writeln!(out, " var {}: HTTPURLResponse?", ctx.response_var);
399 let _ = writeln!(out, " var _responseData: Data?");
400 let _ = writeln!(out, " let _sema = DispatchSemaphore(value: 0)");
401 let _ = writeln!(
402 out,
403 " URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
404 );
405 let _ = writeln!(out, " {} = resp as? HTTPURLResponse", ctx.response_var);
406 let _ = writeln!(out, " _responseData = data");
407 let _ = writeln!(out, " _sema.signal()");
408 let _ = writeln!(out, " }}.resume()");
409 let _ = writeln!(out, " _sema.wait()");
410 let _ = writeln!(out, " let _resp = try XCTUnwrap({})", ctx.response_var);
411 }
412
413 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
414 let _ = writeln!(out, " XCTAssertEqual(_resp.statusCode, {status})");
415 }
416
417 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
418 let lower_name = name.to_lowercase();
419 let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
420 match expected {
421 "<<present>>" => {
422 let _ = writeln!(out, " XCTAssertNotNil({header_expr})");
423 }
424 "<<absent>>" => {
425 let _ = writeln!(out, " XCTAssertNil({header_expr})");
426 }
427 "<<uuid>>" => {
428 let _ = writeln!(out, " let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
429 let _ = writeln!(
430 out,
431 " 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))"
432 );
433 }
434 exact => {
435 let escaped = escape_swift(exact);
436 let _ = writeln!(out, " XCTAssertEqual({header_expr}, \"{escaped}\")");
437 }
438 }
439 }
440
441 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
442 if let serde_json::Value::String(s) = expected {
443 let escaped = escape_swift(s);
444 let _ = writeln!(
445 out,
446 " let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
447 );
448 let _ = writeln!(
449 out,
450 " XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
451 );
452 } else {
453 let json_str = serde_json::to_string(expected).unwrap_or_default();
454 let escaped = escape_swift(&json_str);
455 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
456 let _ = writeln!(
457 out,
458 " let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
459 );
460 let _ = writeln!(
461 out,
462 " let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
463 );
464 let _ = writeln!(
465 out,
466 " XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
467 );
468 }
469 }
470
471 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
472 if let Some(obj) = expected.as_object() {
473 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
474 let _ = writeln!(
475 out,
476 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
477 );
478 for (key, val) in obj {
479 let escaped_key = escape_swift(key);
480 let swift_val = json_to_swift(val);
481 let _ = writeln!(
482 out,
483 " XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
484 );
485 }
486 }
487 }
488
489 fn render_assert_validation_errors(
490 &self,
491 out: &mut String,
492 _response_var: &str,
493 errors: &[ValidationErrorExpectation],
494 ) {
495 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
496 let _ = writeln!(
497 out,
498 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
499 );
500 let _ = writeln!(
501 out,
502 " let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
503 );
504 for ve in errors {
505 let escaped_msg = escape_swift(&ve.msg);
506 let _ = writeln!(
507 out,
508 " XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
509 );
510 }
511 }
512}
513
514fn render_http_test_method(out: &mut String, fixture: &Fixture) {
519 let Some(http) = &fixture.http else {
520 return;
521 };
522
523 if http.expected_response.status_code == 101 {
525 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
526 let description = fixture.description.replace('"', "\\\"");
527 let _ = writeln!(out, " /// {description}");
528 let _ = writeln!(out, " func test{method_name}() throws {{");
529 let _ = writeln!(
530 out,
531 " try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
532 );
533 let _ = writeln!(out, " }}");
534 return;
535 }
536
537 client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
538}
539
540#[allow(clippy::too_many_arguments)]
545fn render_test_method(
546 out: &mut String,
547 fixture: &Fixture,
548 e2e_config: &E2eConfig,
549 _function_name: &str,
550 _result_var: &str,
551 _args: &[crate::config::ArgMapping],
552 field_resolver: &FieldResolver,
553 result_is_simple: bool,
554 enum_fields: &HashSet<String>,
555) {
556 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
558 let lang = "swift";
559 let call_overrides = call_config.overrides.get(lang);
560 let function_name = call_overrides
561 .and_then(|o| o.function.as_ref())
562 .cloned()
563 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
564 let result_var = &call_config.result_var;
565 let args = &call_config.args;
566 let result_is_simple = call_config.result_is_simple || result_is_simple;
568 let result_is_array = call_config.result_is_array;
569
570 let method_name = fixture.id.to_upper_camel_case();
571 let description = &fixture.description;
572 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
573 let is_async = call_config.r#async;
574
575 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id, &function_name);
576
577 let qualified_function_name = function_name.clone();
581
582 if is_async {
583 let _ = writeln!(out, " func test{method_name}() async throws {{");
584 } else {
585 let _ = writeln!(out, " func test{method_name}() throws {{");
586 }
587 let _ = writeln!(out, " // {description}");
588
589 for line in &setup_lines {
590 let _ = writeln!(out, " {line}");
591 }
592
593 if expects_error {
594 if is_async {
595 let _ = writeln!(out, " do {{");
600 let _ = writeln!(out, " _ = try await {qualified_function_name}({args_str})");
601 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
602 let _ = writeln!(out, " }} catch {{");
603 let _ = writeln!(out, " // success");
604 let _ = writeln!(out, " }}");
605 } else {
606 let _ = writeln!(
607 out,
608 " XCTAssertThrowsError(try {qualified_function_name}({args_str}))"
609 );
610 }
611 let _ = writeln!(out, " }}");
612 return;
613 }
614
615 if is_async {
616 let _ = writeln!(
617 out,
618 " let {result_var} = try await {qualified_function_name}({args_str})"
619 );
620 } else {
621 let _ = writeln!(
622 out,
623 " let {result_var} = try {qualified_function_name}({args_str})"
624 );
625 }
626
627 for assertion in &fixture.assertions {
628 render_assertion(
629 out,
630 assertion,
631 result_var,
632 field_resolver,
633 result_is_simple,
634 result_is_array,
635 enum_fields,
636 );
637 }
638
639 let _ = writeln!(out, " }}");
640}
641
642fn build_args_and_setup(
644 input: &serde_json::Value,
645 args: &[crate::config::ArgMapping],
646 fixture_id: &str,
647 function_name: &str,
648) -> (Vec<String>, String) {
649 if args.is_empty() {
650 return (Vec::new(), String::new());
651 }
652
653 let mut setup_lines: Vec<String> = Vec::new();
654 let mut parts: Vec<String> = Vec::new();
655
656 for arg in args {
657 if arg.arg_type == "mock_url" {
658 setup_lines.push(format!(
659 "let {} = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"",
660 arg.name,
661 ));
662 parts.push(arg.name.clone());
663 continue;
664 }
665
666 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
667 let val = input.get(field);
668 match val {
669 None | Some(serde_json::Value::Null) if arg.optional => {
670 let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
675 let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
676 if is_config_arg && !is_batch_fn {
677 parts.push("\"{}\"".to_string());
678 } else {
679 continue;
680 }
681 }
682 None | Some(serde_json::Value::Null) => {
683 let default_val = match arg.arg_type.as_str() {
684 "string" => "\"\"".to_string(),
685 "int" | "integer" => "0".to_string(),
686 "float" | "number" => "0.0".to_string(),
687 "bool" | "boolean" => "false".to_string(),
688 _ => "nil".to_string(),
689 };
690 parts.push(default_val);
691 }
692 Some(v) => {
693 parts.push(json_to_swift(v));
694 }
695 }
696 }
697
698 (setup_lines, parts.join(", "))
699}
700
701fn render_assertion(
702 out: &mut String,
703 assertion: &Assertion,
704 result_var: &str,
705 field_resolver: &FieldResolver,
706 result_is_simple: bool,
707 result_is_array: bool,
708 enum_fields: &HashSet<String>,
709) {
710 if let Some(f) = &assertion.field {
712 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
713 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
714 return;
715 }
716 }
717
718 if let Some(f) = &assertion.field {
723 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
724 let _ = writeln!(
725 out,
726 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
727 );
728 return;
729 }
730 }
731
732 let field_is_enum = assertion
734 .field
735 .as_deref()
736 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
737
738 let field_expr = if result_is_simple {
739 result_var.to_string()
740 } else {
741 match &assertion.field {
742 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
743 _ => result_var.to_string(),
744 }
745 };
746
747 let string_expr = if field_is_enum {
753 format!("{field_expr}.rawValue")
754 } else {
755 format!("{field_expr}.toString()")
756 };
757
758 match assertion.assertion_type.as_str() {
759 "equals" => {
760 if let Some(expected) = &assertion.value {
761 let swift_val = json_to_swift(expected);
762 if expected.is_string() {
763 let field_is_optional = assertion
767 .field
768 .as_deref()
769 .is_some_and(|f| field_resolver.is_optional(f));
770 let trim_expr = if field_is_optional {
771 format!("(({field_expr})?.toString() ?? \"\").trimmingCharacters(in: .whitespaces)")
772 } else {
773 format!("{string_expr}.trimmingCharacters(in: .whitespaces)")
775 };
776 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
777 } else {
778 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
779 }
780 }
781 }
782 "contains" => {
783 if let Some(expected) = &assertion.value {
784 let swift_val = json_to_swift(expected);
785 let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
788 if result_is_simple && result_is_array && no_field {
789 let _ = writeln!(
792 out,
793 " XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
794 );
795 } else {
796 let field_is_array = assertion
798 .field
799 .as_deref()
800 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
801 if field_is_array {
802 let contains_expr =
803 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
804 let _ = writeln!(
805 out,
806 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
807 );
808 } else {
809 let _ = writeln!(
810 out,
811 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
812 );
813 }
814 }
815 }
816 }
817 "contains_all" => {
818 if let Some(values) = &assertion.values {
819 let field_is_array = assertion
821 .field
822 .as_deref()
823 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
824 if field_is_array {
825 let contains_expr =
826 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
827 for val in values {
828 let swift_val = json_to_swift(val);
829 let _ = writeln!(
830 out,
831 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
832 );
833 }
834 } else {
835 for val in values {
836 let swift_val = json_to_swift(val);
837 let _ = writeln!(
838 out,
839 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
840 );
841 }
842 }
843 }
844 }
845 "not_contains" => {
846 if let Some(expected) = &assertion.value {
847 let swift_val = json_to_swift(expected);
848 let _ = writeln!(
849 out,
850 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
851 );
852 }
853 }
854 "not_empty" => {
855 let field_is_optional = assertion
858 .field
859 .as_deref()
860 .is_some_and(|f| field_resolver.is_optional(f));
861 if field_is_optional {
862 let _ = writeln!(out, " XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
863 } else {
864 let _ = writeln!(
866 out,
867 " XCTAssertFalse({string_expr}.isEmpty, \"expected non-empty value\")"
868 );
869 }
870 }
871 "is_empty" => {
872 let field_is_optional = assertion
873 .field
874 .as_deref()
875 .is_some_and(|f| field_resolver.is_optional(f));
876 if field_is_optional {
877 let _ = writeln!(out, " XCTAssertNil({field_expr}, \"expected nil value\")");
878 } else {
879 let _ = writeln!(
880 out,
881 " XCTAssertTrue({string_expr}.isEmpty, \"expected empty value\")"
882 );
883 }
884 }
885 "contains_any" => {
886 if let Some(values) = &assertion.values {
887 let checks: Vec<String> = values
888 .iter()
889 .map(|v| {
890 let swift_val = json_to_swift(v);
891 format!("{string_expr}.contains({swift_val})")
892 })
893 .collect();
894 let joined = checks.join(" || ");
895 let _ = writeln!(
896 out,
897 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
898 );
899 }
900 }
901 "greater_than" => {
902 if let Some(val) = &assertion.value {
903 let swift_val = json_to_swift(val);
904 let field_is_optional = assertion
906 .field
907 .as_deref()
908 .is_some_and(|f| field_resolver.is_optional(f));
909 let compare_expr = if field_is_optional {
910 format!("({field_expr} ?? 0)")
911 } else {
912 field_expr.clone()
913 };
914 let _ = writeln!(out, " XCTAssertGreaterThan({compare_expr}, {swift_val})");
915 }
916 }
917 "less_than" => {
918 if let Some(val) = &assertion.value {
919 let swift_val = json_to_swift(val);
920 let field_is_optional = assertion
921 .field
922 .as_deref()
923 .is_some_and(|f| field_resolver.is_optional(f));
924 let compare_expr = if field_is_optional {
925 format!("({field_expr} ?? 0)")
926 } else {
927 field_expr.clone()
928 };
929 let _ = writeln!(out, " XCTAssertLessThan({compare_expr}, {swift_val})");
930 }
931 }
932 "greater_than_or_equal" => {
933 if let Some(val) = &assertion.value {
934 let swift_val = json_to_swift(val);
935 let field_is_optional = assertion
937 .field
938 .as_deref()
939 .is_some_and(|f| field_resolver.is_optional(f));
940 let compare_expr = if field_is_optional {
941 format!("({field_expr} ?? 0)")
942 } else {
943 field_expr.clone()
944 };
945 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
946 }
947 }
948 "less_than_or_equal" => {
949 if let Some(val) = &assertion.value {
950 let swift_val = json_to_swift(val);
951 let field_is_optional = assertion
952 .field
953 .as_deref()
954 .is_some_and(|f| field_resolver.is_optional(f));
955 let compare_expr = if field_is_optional {
956 format!("({field_expr} ?? 0)")
957 } else {
958 field_expr.clone()
959 };
960 let _ = writeln!(out, " XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
961 }
962 }
963 "starts_with" => {
964 if let Some(expected) = &assertion.value {
965 let swift_val = json_to_swift(expected);
966 let _ = writeln!(
967 out,
968 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
969 );
970 }
971 }
972 "ends_with" => {
973 if let Some(expected) = &assertion.value {
974 let swift_val = json_to_swift(expected);
975 let _ = writeln!(
976 out,
977 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
978 );
979 }
980 }
981 "min_length" => {
982 if let Some(val) = &assertion.value {
983 if let Some(n) = val.as_u64() {
984 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
987 }
988 }
989 }
990 "max_length" => {
991 if let Some(val) = &assertion.value {
992 if let Some(n) = val.as_u64() {
993 let _ = writeln!(out, " XCTAssertLessThanOrEqual({string_expr}.count, {n})");
994 }
995 }
996 }
997 "count_min" => {
998 if let Some(val) = &assertion.value {
999 if let Some(n) = val.as_u64() {
1000 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1004 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1005 }
1006 }
1007 }
1008 "count_equals" => {
1009 if let Some(val) = &assertion.value {
1010 if let Some(n) = val.as_u64() {
1011 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1012 let _ = writeln!(out, " XCTAssertEqual({count_expr}, {n})");
1013 }
1014 }
1015 }
1016 "is_true" => {
1017 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
1018 }
1019 "is_false" => {
1020 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
1021 }
1022 "matches_regex" => {
1023 if let Some(expected) = &assertion.value {
1024 let swift_val = json_to_swift(expected);
1025 let _ = writeln!(
1026 out,
1027 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1028 );
1029 }
1030 }
1031 "not_error" => {
1032 }
1034 "error" => {
1035 }
1037 "method_result" => {
1038 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
1039 }
1040 other => {
1041 panic!("Swift e2e generator: unsupported assertion type: {other}");
1042 }
1043 }
1044}
1045
1046fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1055 let resolved = field_resolver.resolve(field);
1056 let parts: Vec<&str> = resolved.split('.').collect();
1057
1058 let mut out = result_var.to_string();
1061 let mut has_optional = false;
1062 let mut path_so_far = String::new();
1063 let total = parts.len();
1064 for (i, part) in parts.iter().enumerate() {
1065 let is_leaf = i == total - 1;
1066 if !path_so_far.is_empty() {
1067 path_so_far.push('.');
1068 }
1069 path_so_far.push_str(part);
1070 out.push('.');
1071 out.push_str(part);
1072 out.push_str("()");
1073 if !is_leaf && field_resolver.is_optional(&path_so_far) {
1076 out.push('?');
1077 has_optional = true;
1078 }
1079 }
1080 (out, has_optional)
1081}
1082
1083fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1099 let Some(f) = field else {
1100 return format!("{result_var}.map {{ $0.as_str().toString() }}");
1101 };
1102 let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
1103 format!("{accessor}?.map {{ $0.as_str().toString() }}")
1106}
1107
1108fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1114 let Some(f) = field else {
1115 return format!("{result_var}.count");
1116 };
1117 let (accessor, has_optional) = swift_build_accessor(f, result_var, field_resolver);
1118 if has_optional {
1119 format!("{accessor}.count ?? 0")
1120 } else {
1121 format!("{accessor}.count")
1122 }
1123}
1124
1125fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
1131 let mut components = std::path::PathBuf::new();
1132 for component in path.components() {
1133 match component {
1134 std::path::Component::ParentDir => {
1135 if !components.as_os_str().is_empty() {
1138 components.pop();
1139 } else {
1140 components.push(component);
1141 }
1142 }
1143 std::path::Component::CurDir => {}
1144 other => components.push(other),
1145 }
1146 }
1147 components
1148}
1149
1150fn json_to_swift(value: &serde_json::Value) -> String {
1152 match value {
1153 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
1154 serde_json::Value::Bool(b) => b.to_string(),
1155 serde_json::Value::Number(n) => n.to_string(),
1156 serde_json::Value::Null => "nil".to_string(),
1157 serde_json::Value::Array(arr) => {
1158 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
1159 format!("[{}]", items.join(", "))
1160 }
1161 serde_json::Value::Object(_) => {
1162 let json_str = serde_json::to_string(value).unwrap_or_default();
1163 format!("\"{}\"", escape_swift(&json_str))
1164 }
1165 }
1166}
1167
1168fn escape_swift(s: &str) -> String {
1170 escape_swift_str(s)
1171}