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!(out, " .appendingPathComponent(\"test_documents\")");
286 let _ = writeln!(
287 out,
288 " if FileManager.default.fileExists(atPath: _testDocs.path) {{"
289 );
290 let _ = writeln!(
291 out,
292 " FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
293 );
294 let _ = writeln!(out, " }}");
295 let _ = writeln!(out, " }}");
296 let _ = writeln!(out);
297 }
298
299 for fixture in fixtures {
300 if fixture.is_http_test() {
301 render_http_test_method(&mut out, fixture);
302 } else {
303 render_test_method(
304 &mut out,
305 fixture,
306 e2e_config,
307 function_name,
308 result_var,
309 args,
310 field_resolver,
311 result_is_simple,
312 enum_fields,
313 );
314 }
315 let _ = writeln!(out);
316 }
317
318 let _ = writeln!(out, "}}");
319 out
320}
321
322struct SwiftTestClientRenderer;
329
330impl client::TestClientRenderer for SwiftTestClientRenderer {
331 fn language_name(&self) -> &'static str {
332 "swift"
333 }
334
335 fn sanitize_test_name(&self, id: &str) -> String {
336 sanitize_ident(id).to_upper_camel_case()
338 }
339
340 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
346 let _ = writeln!(out, " /// {description}");
347 let _ = writeln!(out, " func test{fn_name}() throws {{");
348 if let Some(reason) = skip_reason {
349 let escaped = escape_swift(reason);
350 let _ = writeln!(out, " try XCTSkipIf(true, \"{escaped}\")");
351 }
352 }
353
354 fn render_test_close(&self, out: &mut String) {
355 let _ = writeln!(out, " }}");
356 }
357
358 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
365 let method = ctx.method.to_uppercase();
366 let fixture_path = escape_swift(ctx.path);
367
368 let _ = writeln!(
369 out,
370 " let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
371 );
372 let _ = writeln!(
373 out,
374 " var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
375 );
376 let _ = writeln!(out, " _req.httpMethod = \"{method}\"");
377
378 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
380 header_pairs.sort_by_key(|(k, _)| k.as_str());
381 for (k, v) in &header_pairs {
382 let expanded_v = expand_fixture_templates(v);
383 let ek = escape_swift(k);
384 let ev = escape_swift(&expanded_v);
385 let _ = writeln!(out, " _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
386 }
387
388 if let Some(body) = ctx.body {
390 let json_str = serde_json::to_string(body).unwrap_or_default();
391 let escaped_body = escape_swift(&json_str);
392 let _ = writeln!(out, " _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
393 let _ = writeln!(
394 out,
395 " _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
396 );
397 }
398
399 let _ = writeln!(out, " var {}: HTTPURLResponse?", ctx.response_var);
400 let _ = writeln!(out, " var _responseData: Data?");
401 let _ = writeln!(out, " let _sema = DispatchSemaphore(value: 0)");
402 let _ = writeln!(
403 out,
404 " URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
405 );
406 let _ = writeln!(out, " {} = resp as? HTTPURLResponse", ctx.response_var);
407 let _ = writeln!(out, " _responseData = data");
408 let _ = writeln!(out, " _sema.signal()");
409 let _ = writeln!(out, " }}.resume()");
410 let _ = writeln!(out, " _sema.wait()");
411 let _ = writeln!(out, " let _resp = try XCTUnwrap({})", ctx.response_var);
412 }
413
414 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
415 let _ = writeln!(out, " XCTAssertEqual(_resp.statusCode, {status})");
416 }
417
418 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
419 let lower_name = name.to_lowercase();
420 let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
421 match expected {
422 "<<present>>" => {
423 let _ = writeln!(out, " XCTAssertNotNil({header_expr})");
424 }
425 "<<absent>>" => {
426 let _ = writeln!(out, " XCTAssertNil({header_expr})");
427 }
428 "<<uuid>>" => {
429 let _ = writeln!(out, " let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
430 let _ = writeln!(
431 out,
432 " 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))"
433 );
434 }
435 exact => {
436 let escaped = escape_swift(exact);
437 let _ = writeln!(out, " XCTAssertEqual({header_expr}, \"{escaped}\")");
438 }
439 }
440 }
441
442 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
443 if let serde_json::Value::String(s) = expected {
444 let escaped = escape_swift(s);
445 let _ = writeln!(
446 out,
447 " let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
448 );
449 let _ = writeln!(
450 out,
451 " XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
452 );
453 } else {
454 let json_str = serde_json::to_string(expected).unwrap_or_default();
455 let escaped = escape_swift(&json_str);
456 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
457 let _ = writeln!(
458 out,
459 " let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
460 );
461 let _ = writeln!(
462 out,
463 " let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
464 );
465 let _ = writeln!(
466 out,
467 " XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
468 );
469 }
470 }
471
472 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
473 if let Some(obj) = expected.as_object() {
474 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
475 let _ = writeln!(
476 out,
477 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
478 );
479 for (key, val) in obj {
480 let escaped_key = escape_swift(key);
481 let swift_val = json_to_swift(val);
482 let _ = writeln!(
483 out,
484 " XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
485 );
486 }
487 }
488 }
489
490 fn render_assert_validation_errors(
491 &self,
492 out: &mut String,
493 _response_var: &str,
494 errors: &[ValidationErrorExpectation],
495 ) {
496 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
497 let _ = writeln!(
498 out,
499 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
500 );
501 let _ = writeln!(
502 out,
503 " let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
504 );
505 for ve in errors {
506 let escaped_msg = escape_swift(&ve.msg);
507 let _ = writeln!(
508 out,
509 " XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
510 );
511 }
512 }
513}
514
515fn render_http_test_method(out: &mut String, fixture: &Fixture) {
520 let Some(http) = &fixture.http else {
521 return;
522 };
523
524 if http.expected_response.status_code == 101 {
526 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
527 let description = fixture.description.replace('"', "\\\"");
528 let _ = writeln!(out, " /// {description}");
529 let _ = writeln!(out, " func test{method_name}() throws {{");
530 let _ = writeln!(
531 out,
532 " try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
533 );
534 let _ = writeln!(out, " }}");
535 return;
536 }
537
538 client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
539}
540
541#[allow(clippy::too_many_arguments)]
546fn render_test_method(
547 out: &mut String,
548 fixture: &Fixture,
549 e2e_config: &E2eConfig,
550 _function_name: &str,
551 _result_var: &str,
552 _args: &[crate::config::ArgMapping],
553 field_resolver: &FieldResolver,
554 result_is_simple: bool,
555 enum_fields: &HashSet<String>,
556) {
557 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
559 let lang = "swift";
560 let call_overrides = call_config.overrides.get(lang);
561 let function_name = call_overrides
562 .and_then(|o| o.function.as_ref())
563 .cloned()
564 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
565 let result_var = &call_config.result_var;
566 let args = &call_config.args;
567 let result_is_simple = call_config.result_is_simple || result_is_simple;
569 let result_is_array = call_config.result_is_array;
570
571 let method_name = fixture.id.to_upper_camel_case();
572 let description = &fixture.description;
573 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
574 let is_async = call_config.r#async;
575
576 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id, &function_name);
577
578 let qualified_function_name = function_name.clone();
582
583 if is_async {
584 let _ = writeln!(out, " func test{method_name}() async throws {{");
585 } else {
586 let _ = writeln!(out, " func test{method_name}() throws {{");
587 }
588 let _ = writeln!(out, " // {description}");
589
590 for line in &setup_lines {
591 let _ = writeln!(out, " {line}");
592 }
593
594 if expects_error {
595 if is_async {
596 let _ = writeln!(out, " do {{");
601 let _ = writeln!(out, " _ = try await {qualified_function_name}({args_str})");
602 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
603 let _ = writeln!(out, " }} catch {{");
604 let _ = writeln!(out, " // success");
605 let _ = writeln!(out, " }}");
606 } else {
607 let _ = writeln!(
608 out,
609 " XCTAssertThrowsError(try {qualified_function_name}({args_str}))"
610 );
611 }
612 let _ = writeln!(out, " }}");
613 return;
614 }
615
616 if is_async {
617 let _ = writeln!(
618 out,
619 " let {result_var} = try await {qualified_function_name}({args_str})"
620 );
621 } else {
622 let _ = writeln!(
623 out,
624 " let {result_var} = try {qualified_function_name}({args_str})"
625 );
626 }
627
628 for assertion in &fixture.assertions {
629 render_assertion(
630 out,
631 assertion,
632 result_var,
633 field_resolver,
634 result_is_simple,
635 result_is_array,
636 enum_fields,
637 );
638 }
639
640 let _ = writeln!(out, " }}");
641}
642
643fn build_args_and_setup(
657 input: &serde_json::Value,
658 args: &[crate::config::ArgMapping],
659 fixture_id: &str,
660 function_name: &str,
661) -> (Vec<String>, String) {
662 if args.is_empty() {
663 return (Vec::new(), String::new());
664 }
665
666 let mut setup_lines: Vec<String> = Vec::new();
667 let mut parts: Vec<String> = Vec::new();
668
669 let later_emits: Vec<bool> = (0..args.len())
674 .map(|i| {
675 args.iter().skip(i + 1).any(|a| {
676 let f = a.field.strip_prefix("input.").unwrap_or(&a.field);
677 let v = input.get(f);
678 let has_value = matches!(v, Some(x) if !x.is_null());
679 has_value || !a.optional || (a.arg_type == "json_object" && a.name == "config")
680 })
681 })
682 .collect();
683
684 for (idx, arg) in args.iter().enumerate() {
685 if arg.arg_type == "mock_url" {
686 setup_lines.push(format!(
687 "let {} = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"",
688 arg.name,
689 ));
690 parts.push(arg.name.clone());
691 continue;
692 }
693
694 if arg.arg_type == "bytes" {
699 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
700 let val = input.get(field);
701 match val {
702 None | Some(serde_json::Value::Null) if arg.optional => {
703 if later_emits[idx] {
704 parts.push("nil".to_string());
705 }
706 }
707 None | Some(serde_json::Value::Null) => {
708 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
709 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
710 parts.push(var_name);
711 }
712 Some(serde_json::Value::String(s)) => {
713 let escaped = escape_swift(s);
714 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
715 let data_var = format!("{}Data", arg.name.to_lower_camel_case());
716 setup_lines.push(format!(
717 "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
718 ));
719 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
720 setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
721 parts.push(var_name);
722 }
723 Some(serde_json::Value::Array(arr)) => {
724 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
725 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
726 for v in arr {
727 if let Some(n) = v.as_u64() {
728 setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
729 }
730 }
731 parts.push(var_name);
732 }
733 Some(other) => {
734 let json_str = serde_json::to_string(other).unwrap_or_default();
736 let escaped = escape_swift(&json_str);
737 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
738 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
739 setup_lines.push(format!(
740 "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
741 ));
742 parts.push(var_name);
743 }
744 }
745 continue;
746 }
747
748 let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
753 let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
754 if is_config_arg && !is_batch_fn {
755 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
756 let val = input.get(field);
757 let json_str = match val {
758 None | Some(serde_json::Value::Null) => "{}".to_string(),
759 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
760 };
761 let escaped = escape_swift(&json_str);
762 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
763 setup_lines.push(format!("let {var_name} = try extractionConfigFromJson(\"{escaped}\")"));
764 parts.push(var_name);
765 continue;
766 }
767
768 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
769 let val = input.get(field);
770 match val {
771 None | Some(serde_json::Value::Null) if arg.optional => {
772 if later_emits[idx] {
776 parts.push("nil".to_string());
777 }
778 }
779 None | Some(serde_json::Value::Null) => {
780 let default_val = match arg.arg_type.as_str() {
781 "string" => "\"\"".to_string(),
782 "int" | "integer" => "0".to_string(),
783 "float" | "number" => "0.0".to_string(),
784 "bool" | "boolean" => "false".to_string(),
785 _ => "nil".to_string(),
786 };
787 parts.push(default_val);
788 }
789 Some(v) => {
790 parts.push(json_to_swift(v));
791 }
792 }
793 }
794
795 (setup_lines, parts.join(", "))
796}
797
798fn render_assertion(
799 out: &mut String,
800 assertion: &Assertion,
801 result_var: &str,
802 field_resolver: &FieldResolver,
803 result_is_simple: bool,
804 result_is_array: bool,
805 enum_fields: &HashSet<String>,
806) {
807 if let Some(f) = &assertion.field {
809 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
810 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
811 return;
812 }
813 }
814
815 if let Some(f) = &assertion.field {
820 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
821 let _ = writeln!(
822 out,
823 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
824 );
825 return;
826 }
827 }
828
829 let field_is_enum = assertion
831 .field
832 .as_deref()
833 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
834
835 let field_expr = if result_is_simple {
836 result_var.to_string()
837 } else {
838 match &assertion.field {
839 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
840 _ => result_var.to_string(),
841 }
842 };
843
844 let string_expr = if field_is_enum {
850 format!("{field_expr}.rawValue")
851 } else {
852 format!("{field_expr}.toString()")
853 };
854
855 match assertion.assertion_type.as_str() {
856 "equals" => {
857 if let Some(expected) = &assertion.value {
858 let swift_val = json_to_swift(expected);
859 if expected.is_string() {
860 let field_is_optional = assertion
864 .field
865 .as_deref()
866 .is_some_and(|f| field_resolver.is_optional(f));
867 let trim_expr = if field_is_optional {
868 format!("(({field_expr})?.toString() ?? \"\").trimmingCharacters(in: .whitespaces)")
869 } else {
870 format!("{string_expr}.trimmingCharacters(in: .whitespaces)")
872 };
873 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
874 } else {
875 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
876 }
877 }
878 }
879 "contains" => {
880 if let Some(expected) = &assertion.value {
881 let swift_val = json_to_swift(expected);
882 let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
885 if result_is_simple && result_is_array && no_field {
886 let _ = writeln!(
889 out,
890 " XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
891 );
892 } else {
893 let field_is_array = assertion
895 .field
896 .as_deref()
897 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
898 if field_is_array {
899 let contains_expr =
900 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
901 let _ = writeln!(
902 out,
903 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
904 );
905 } else {
906 let _ = writeln!(
907 out,
908 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
909 );
910 }
911 }
912 }
913 }
914 "contains_all" => {
915 if let Some(values) = &assertion.values {
916 let field_is_array = assertion
918 .field
919 .as_deref()
920 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
921 if field_is_array {
922 let contains_expr =
923 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
924 for val in values {
925 let swift_val = json_to_swift(val);
926 let _ = writeln!(
927 out,
928 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
929 );
930 }
931 } else {
932 for val in values {
933 let swift_val = json_to_swift(val);
934 let _ = writeln!(
935 out,
936 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
937 );
938 }
939 }
940 }
941 }
942 "not_contains" => {
943 if let Some(expected) = &assertion.value {
944 let swift_val = json_to_swift(expected);
945 let _ = writeln!(
946 out,
947 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
948 );
949 }
950 }
951 "not_empty" => {
952 let field_is_optional = assertion
955 .field
956 .as_deref()
957 .is_some_and(|f| field_resolver.is_optional(f));
958 if field_is_optional {
959 let _ = writeln!(out, " XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
960 } else {
961 let _ = writeln!(
963 out,
964 " XCTAssertFalse({string_expr}.isEmpty, \"expected non-empty value\")"
965 );
966 }
967 }
968 "is_empty" => {
969 let field_is_optional = assertion
970 .field
971 .as_deref()
972 .is_some_and(|f| field_resolver.is_optional(f));
973 if field_is_optional {
974 let _ = writeln!(out, " XCTAssertNil({field_expr}, \"expected nil value\")");
975 } else {
976 let _ = writeln!(
977 out,
978 " XCTAssertTrue({string_expr}.isEmpty, \"expected empty value\")"
979 );
980 }
981 }
982 "contains_any" => {
983 if let Some(values) = &assertion.values {
984 let checks: Vec<String> = values
985 .iter()
986 .map(|v| {
987 let swift_val = json_to_swift(v);
988 format!("{string_expr}.contains({swift_val})")
989 })
990 .collect();
991 let joined = checks.join(" || ");
992 let _ = writeln!(
993 out,
994 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
995 );
996 }
997 }
998 "greater_than" => {
999 if let Some(val) = &assertion.value {
1000 let swift_val = json_to_swift(val);
1001 let field_is_optional = assertion
1003 .field
1004 .as_deref()
1005 .is_some_and(|f| field_resolver.is_optional(f));
1006 let compare_expr = if field_is_optional {
1007 format!("({field_expr} ?? 0)")
1008 } else {
1009 field_expr.clone()
1010 };
1011 let _ = writeln!(out, " XCTAssertGreaterThan({compare_expr}, {swift_val})");
1012 }
1013 }
1014 "less_than" => {
1015 if let Some(val) = &assertion.value {
1016 let swift_val = json_to_swift(val);
1017 let field_is_optional = assertion
1018 .field
1019 .as_deref()
1020 .is_some_and(|f| field_resolver.is_optional(f));
1021 let compare_expr = if field_is_optional {
1022 format!("({field_expr} ?? 0)")
1023 } else {
1024 field_expr.clone()
1025 };
1026 let _ = writeln!(out, " XCTAssertLessThan({compare_expr}, {swift_val})");
1027 }
1028 }
1029 "greater_than_or_equal" => {
1030 if let Some(val) = &assertion.value {
1031 let swift_val = json_to_swift(val);
1032 let field_is_optional = assertion
1034 .field
1035 .as_deref()
1036 .is_some_and(|f| field_resolver.is_optional(f));
1037 let compare_expr = if field_is_optional {
1038 format!("({field_expr} ?? 0)")
1039 } else {
1040 field_expr.clone()
1041 };
1042 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
1043 }
1044 }
1045 "less_than_or_equal" => {
1046 if let Some(val) = &assertion.value {
1047 let swift_val = json_to_swift(val);
1048 let field_is_optional = assertion
1049 .field
1050 .as_deref()
1051 .is_some_and(|f| field_resolver.is_optional(f));
1052 let compare_expr = if field_is_optional {
1053 format!("({field_expr} ?? 0)")
1054 } else {
1055 field_expr.clone()
1056 };
1057 let _ = writeln!(out, " XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
1058 }
1059 }
1060 "starts_with" => {
1061 if let Some(expected) = &assertion.value {
1062 let swift_val = json_to_swift(expected);
1063 let _ = writeln!(
1064 out,
1065 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1066 );
1067 }
1068 }
1069 "ends_with" => {
1070 if let Some(expected) = &assertion.value {
1071 let swift_val = json_to_swift(expected);
1072 let _ = writeln!(
1073 out,
1074 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1075 );
1076 }
1077 }
1078 "min_length" => {
1079 if let Some(val) = &assertion.value {
1080 if let Some(n) = val.as_u64() {
1081 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1084 }
1085 }
1086 }
1087 "max_length" => {
1088 if let Some(val) = &assertion.value {
1089 if let Some(n) = val.as_u64() {
1090 let _ = writeln!(out, " XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1091 }
1092 }
1093 }
1094 "count_min" => {
1095 if let Some(val) = &assertion.value {
1096 if let Some(n) = val.as_u64() {
1097 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1101 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1102 }
1103 }
1104 }
1105 "count_equals" => {
1106 if let Some(val) = &assertion.value {
1107 if let Some(n) = val.as_u64() {
1108 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1109 let _ = writeln!(out, " XCTAssertEqual({count_expr}, {n})");
1110 }
1111 }
1112 }
1113 "is_true" => {
1114 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
1115 }
1116 "is_false" => {
1117 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
1118 }
1119 "matches_regex" => {
1120 if let Some(expected) = &assertion.value {
1121 let swift_val = json_to_swift(expected);
1122 let _ = writeln!(
1123 out,
1124 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1125 );
1126 }
1127 }
1128 "not_error" => {
1129 }
1131 "error" => {
1132 }
1134 "method_result" => {
1135 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
1136 }
1137 other => {
1138 panic!("Swift e2e generator: unsupported assertion type: {other}");
1139 }
1140 }
1141}
1142
1143fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1152 let resolved = field_resolver.resolve(field);
1153 let parts: Vec<&str> = resolved.split('.').collect();
1154
1155 let mut out = result_var.to_string();
1158 let mut has_optional = false;
1159 let mut path_so_far = String::new();
1160 let total = parts.len();
1161 for (i, part) in parts.iter().enumerate() {
1162 let is_leaf = i == total - 1;
1163 if !path_so_far.is_empty() {
1164 path_so_far.push('.');
1165 }
1166 path_so_far.push_str(part);
1167 out.push('.');
1168 out.push_str(part);
1169 out.push_str("()");
1170 if !is_leaf && field_resolver.is_optional(&path_so_far) {
1173 out.push('?');
1174 has_optional = true;
1175 }
1176 }
1177 (out, has_optional)
1178}
1179
1180fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1196 let Some(f) = field else {
1197 return format!("{result_var}.map {{ $0.as_str().toString() }}");
1198 };
1199 let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
1200 format!("{accessor}?.map {{ $0.as_str().toString() }}")
1203}
1204
1205fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1211 let Some(f) = field else {
1212 return format!("{result_var}.count");
1213 };
1214 let (accessor, has_optional) = swift_build_accessor(f, result_var, field_resolver);
1215 if has_optional {
1216 format!("{accessor}.count ?? 0")
1217 } else {
1218 format!("{accessor}.count")
1219 }
1220}
1221
1222fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
1228 let mut components = std::path::PathBuf::new();
1229 for component in path.components() {
1230 match component {
1231 std::path::Component::ParentDir => {
1232 if !components.as_os_str().is_empty() {
1235 components.pop();
1236 } else {
1237 components.push(component);
1238 }
1239 }
1240 std::path::Component::CurDir => {}
1241 other => components.push(other),
1242 }
1243 }
1244 components
1245}
1246
1247fn json_to_swift(value: &serde_json::Value) -> String {
1249 match value {
1250 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
1251 serde_json::Value::Bool(b) => b.to_string(),
1252 serde_json::Value::Number(n) => n.to_string(),
1253 serde_json::Value::Null => "nil".to_string(),
1254 serde_json::Value::Array(arr) => {
1255 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
1256 format!("[{}]", items.join(", "))
1257 }
1258 serde_json::Value::Object(_) => {
1259 let json_str = serde_json::to_string(value).unwrap_or_default();
1260 format!("\"{}\"", escape_swift(&json_str))
1261 }
1262 }
1263}
1264
1265fn escape_swift(s: &str) -> String {
1267 escape_swift_str(s)
1268}