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 == "handle" {
699 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
700 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
701 parts.push(var_name);
702 continue;
703 }
704
705 if arg.arg_type == "bytes" {
710 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
711 let val = input.get(field);
712 match val {
713 None | Some(serde_json::Value::Null) if arg.optional => {
714 if later_emits[idx] {
715 parts.push("nil".to_string());
716 }
717 }
718 None | Some(serde_json::Value::Null) => {
719 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
720 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
721 parts.push(var_name);
722 }
723 Some(serde_json::Value::String(s)) => {
724 let escaped = escape_swift(s);
725 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
726 let data_var = format!("{}Data", arg.name.to_lower_camel_case());
727 setup_lines.push(format!(
728 "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
729 ));
730 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
731 setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
732 parts.push(var_name);
733 }
734 Some(serde_json::Value::Array(arr)) => {
735 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
736 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
737 for v in arr {
738 if let Some(n) = v.as_u64() {
739 setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
740 }
741 }
742 parts.push(var_name);
743 }
744 Some(other) => {
745 let json_str = serde_json::to_string(other).unwrap_or_default();
747 let escaped = escape_swift(&json_str);
748 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
749 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
750 setup_lines.push(format!(
751 "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
752 ));
753 parts.push(var_name);
754 }
755 }
756 continue;
757 }
758
759 let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
764 let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
765 if is_config_arg && !is_batch_fn {
766 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
767 let val = input.get(field);
768 let json_str = match val {
769 None | Some(serde_json::Value::Null) => "{}".to_string(),
770 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
771 };
772 let escaped = escape_swift(&json_str);
773 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
774 setup_lines.push(format!("let {var_name} = try extractionConfigFromJson(\"{escaped}\")"));
775 parts.push(var_name);
776 continue;
777 }
778
779 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
780 let val = input.get(field);
781 match val {
782 None | Some(serde_json::Value::Null) if arg.optional => {
783 if later_emits[idx] {
787 parts.push("nil".to_string());
788 }
789 }
790 None | Some(serde_json::Value::Null) => {
791 let default_val = match arg.arg_type.as_str() {
792 "string" => "\"\"".to_string(),
793 "int" | "integer" => "0".to_string(),
794 "float" | "number" => "0.0".to_string(),
795 "bool" | "boolean" => "false".to_string(),
796 _ => "nil".to_string(),
797 };
798 parts.push(default_val);
799 }
800 Some(v) => {
801 parts.push(json_to_swift(v));
802 }
803 }
804 }
805
806 (setup_lines, parts.join(", "))
807}
808
809fn render_assertion(
810 out: &mut String,
811 assertion: &Assertion,
812 result_var: &str,
813 field_resolver: &FieldResolver,
814 result_is_simple: bool,
815 result_is_array: bool,
816 enum_fields: &HashSet<String>,
817) {
818 if let Some(f) = &assertion.field {
820 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
821 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
822 return;
823 }
824 }
825
826 if let Some(f) = &assertion.field {
831 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
832 let _ = writeln!(
833 out,
834 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
835 );
836 return;
837 }
838 }
839
840 let field_is_enum = assertion
842 .field
843 .as_deref()
844 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
845
846 let field_is_optional = assertion
847 .field
848 .as_deref()
849 .is_some_and(|f| !f.is_empty() && field_resolver.is_optional(f));
850 let field_is_array = assertion.field.as_deref().is_some_and(|f| {
851 !f.is_empty() && (field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f)))
852 });
853
854 let field_expr = if result_is_simple {
855 result_var.to_string()
856 } else {
857 match &assertion.field {
858 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
859 _ => result_var.to_string(),
860 }
861 };
862
863 let string_expr = if field_is_enum {
870 format!("{field_expr}.rawValue")
871 } else if field_is_optional {
872 format!("({field_expr}?.toString() ?? \"\")")
873 } else {
874 format!("{field_expr}.toString()")
875 };
876
877 match assertion.assertion_type.as_str() {
878 "equals" => {
879 if let Some(expected) = &assertion.value {
880 let swift_val = json_to_swift(expected);
881 if expected.is_string() {
882 let trim_expr = format!("{string_expr}.trimmingCharacters(in: .whitespaces)");
887 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
888 } else {
889 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
890 }
891 }
892 }
893 "contains" => {
894 if let Some(expected) = &assertion.value {
895 let swift_val = json_to_swift(expected);
896 let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
899 if result_is_simple && result_is_array && no_field {
900 let _ = writeln!(
903 out,
904 " XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
905 );
906 } else {
907 let field_is_array = assertion
909 .field
910 .as_deref()
911 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
912 if field_is_array {
913 let contains_expr =
914 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
915 let _ = writeln!(
916 out,
917 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
918 );
919 } else {
920 let _ = writeln!(
921 out,
922 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
923 );
924 }
925 }
926 }
927 }
928 "contains_all" => {
929 if let Some(values) = &assertion.values {
930 let field_is_array = assertion
932 .field
933 .as_deref()
934 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
935 if field_is_array {
936 let contains_expr =
937 swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
938 for val in values {
939 let swift_val = json_to_swift(val);
940 let _ = writeln!(
941 out,
942 " XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
943 );
944 }
945 } else {
946 for val in values {
947 let swift_val = json_to_swift(val);
948 let _ = writeln!(
949 out,
950 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
951 );
952 }
953 }
954 }
955 }
956 "not_contains" => {
957 if let Some(expected) = &assertion.value {
958 let swift_val = json_to_swift(expected);
959 let _ = writeln!(
960 out,
961 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
962 );
963 }
964 }
965 "not_empty" => {
966 if field_is_optional {
970 let _ = writeln!(out, " XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
971 } else if field_is_array {
972 let _ = writeln!(
973 out,
974 " XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
975 );
976 } else {
977 let _ = writeln!(
979 out,
980 " XCTAssertFalse({string_expr}.isEmpty, \"expected non-empty value\")"
981 );
982 }
983 }
984 "is_empty" => {
985 if field_is_optional {
986 let _ = writeln!(out, " XCTAssertNil({field_expr}, \"expected nil value\")");
987 } else if field_is_array {
988 let _ = writeln!(
989 out,
990 " XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
991 );
992 } else {
993 let _ = writeln!(
994 out,
995 " XCTAssertTrue({string_expr}.isEmpty, \"expected empty value\")"
996 );
997 }
998 }
999 "contains_any" => {
1000 if let Some(values) = &assertion.values {
1001 let checks: Vec<String> = values
1002 .iter()
1003 .map(|v| {
1004 let swift_val = json_to_swift(v);
1005 format!("{string_expr}.contains({swift_val})")
1006 })
1007 .collect();
1008 let joined = checks.join(" || ");
1009 let _ = writeln!(
1010 out,
1011 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
1012 );
1013 }
1014 }
1015 "greater_than" => {
1016 if let Some(val) = &assertion.value {
1017 let swift_val = json_to_swift(val);
1018 let field_is_optional = assertion
1020 .field
1021 .as_deref()
1022 .is_some_and(|f| field_resolver.is_optional(f));
1023 let compare_expr = if field_is_optional {
1024 format!("({field_expr} ?? 0)")
1025 } else {
1026 field_expr.clone()
1027 };
1028 let _ = writeln!(out, " XCTAssertGreaterThan({compare_expr}, {swift_val})");
1029 }
1030 }
1031 "less_than" => {
1032 if let Some(val) = &assertion.value {
1033 let swift_val = json_to_swift(val);
1034 let field_is_optional = assertion
1035 .field
1036 .as_deref()
1037 .is_some_and(|f| field_resolver.is_optional(f));
1038 let compare_expr = if field_is_optional {
1039 format!("({field_expr} ?? 0)")
1040 } else {
1041 field_expr.clone()
1042 };
1043 let _ = writeln!(out, " XCTAssertLessThan({compare_expr}, {swift_val})");
1044 }
1045 }
1046 "greater_than_or_equal" => {
1047 if let Some(val) = &assertion.value {
1048 let swift_val = json_to_swift(val);
1049 let field_is_optional = assertion
1051 .field
1052 .as_deref()
1053 .is_some_and(|f| field_resolver.is_optional(f));
1054 let compare_expr = if field_is_optional {
1055 format!("({field_expr} ?? 0)")
1056 } else {
1057 field_expr.clone()
1058 };
1059 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
1060 }
1061 }
1062 "less_than_or_equal" => {
1063 if let Some(val) = &assertion.value {
1064 let swift_val = json_to_swift(val);
1065 let field_is_optional = assertion
1066 .field
1067 .as_deref()
1068 .is_some_and(|f| field_resolver.is_optional(f));
1069 let compare_expr = if field_is_optional {
1070 format!("({field_expr} ?? 0)")
1071 } else {
1072 field_expr.clone()
1073 };
1074 let _ = writeln!(out, " XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
1075 }
1076 }
1077 "starts_with" => {
1078 if let Some(expected) = &assertion.value {
1079 let swift_val = json_to_swift(expected);
1080 let _ = writeln!(
1081 out,
1082 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1083 );
1084 }
1085 }
1086 "ends_with" => {
1087 if let Some(expected) = &assertion.value {
1088 let swift_val = json_to_swift(expected);
1089 let _ = writeln!(
1090 out,
1091 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1092 );
1093 }
1094 }
1095 "min_length" => {
1096 if let Some(val) = &assertion.value {
1097 if let Some(n) = val.as_u64() {
1098 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1101 }
1102 }
1103 }
1104 "max_length" => {
1105 if let Some(val) = &assertion.value {
1106 if let Some(n) = val.as_u64() {
1107 let _ = writeln!(out, " XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1108 }
1109 }
1110 }
1111 "count_min" => {
1112 if let Some(val) = &assertion.value {
1113 if let Some(n) = val.as_u64() {
1114 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1118 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1119 }
1120 }
1121 }
1122 "count_equals" => {
1123 if let Some(val) = &assertion.value {
1124 if let Some(n) = val.as_u64() {
1125 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1126 let _ = writeln!(out, " XCTAssertEqual({count_expr}, {n})");
1127 }
1128 }
1129 }
1130 "is_true" => {
1131 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
1132 }
1133 "is_false" => {
1134 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
1135 }
1136 "matches_regex" => {
1137 if let Some(expected) = &assertion.value {
1138 let swift_val = json_to_swift(expected);
1139 let _ = writeln!(
1140 out,
1141 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1142 );
1143 }
1144 }
1145 "not_error" => {
1146 }
1148 "error" => {
1149 }
1151 "method_result" => {
1152 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
1153 }
1154 other => {
1155 panic!("Swift e2e generator: unsupported assertion type: {other}");
1156 }
1157 }
1158}
1159
1160fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1169 let resolved = field_resolver.resolve(field);
1170 let parts: Vec<&str> = resolved.split('.').collect();
1171
1172 let mut out = result_var.to_string();
1175 let mut has_optional = false;
1176 let mut path_so_far = String::new();
1177 let total = parts.len();
1178 for (i, part) in parts.iter().enumerate() {
1179 let is_leaf = i == total - 1;
1180 if !path_so_far.is_empty() {
1181 path_so_far.push('.');
1182 }
1183 path_so_far.push_str(part);
1184 out.push('.');
1185 out.push_str(part);
1186 out.push_str("()");
1187 if !is_leaf && field_resolver.is_optional(&path_so_far) {
1190 out.push('?');
1191 has_optional = true;
1192 }
1193 }
1194 (out, has_optional)
1195}
1196
1197fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1213 let Some(f) = field else {
1214 return format!("{result_var}.map {{ $0.as_str().toString() }}");
1215 };
1216 let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
1217 format!("{accessor}?.map {{ $0.as_str().toString() }}")
1220}
1221
1222fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1228 let Some(f) = field else {
1229 return format!("{result_var}.count");
1230 };
1231 let (accessor, has_optional) = swift_build_accessor(f, result_var, field_resolver);
1232 if has_optional {
1233 format!("{accessor}.count ?? 0")
1234 } else {
1235 format!("{accessor}.count")
1236 }
1237}
1238
1239fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
1245 let mut components = std::path::PathBuf::new();
1246 for component in path.components() {
1247 match component {
1248 std::path::Component::ParentDir => {
1249 if !components.as_os_str().is_empty() {
1252 components.pop();
1253 } else {
1254 components.push(component);
1255 }
1256 }
1257 std::path::Component::CurDir => {}
1258 other => components.push(other),
1259 }
1260 }
1261 components
1262}
1263
1264fn json_to_swift(value: &serde_json::Value) -> String {
1266 match value {
1267 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
1268 serde_json::Value::Bool(b) => b.to_string(),
1269 serde_json::Value::Number(n) => n.to_string(),
1270 serde_json::Value::Null => "nil".to_string(),
1271 serde_json::Value::Array(arr) => {
1272 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
1273 format!("[{}]", items.join(", "))
1274 }
1275 serde_json::Value::Object(_) => {
1276 let json_str = serde_json::to_string(value).unwrap_or_default();
1277 format!("\"{}\"", escape_swift(&json_str))
1278 }
1279 }
1280}
1281
1282fn escape_swift(s: &str) -> String {
1284 escape_swift_str(s)
1285}