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 &HashSet::new(),
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 mut out = String::new();
245 out.push_str(&hash::header(CommentStyle::DoubleSlash));
246 let _ = writeln!(out, "import XCTest");
247 let _ = writeln!(out, "import {module_name}");
248 let _ = writeln!(out, "import RustBridge");
249 let _ = writeln!(out);
250 let _ = writeln!(out, "/// E2e tests for category: {category}.");
251 let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
252
253 for fixture in fixtures {
254 if fixture.is_http_test() {
255 render_http_test_method(&mut out, fixture);
256 } else {
257 render_test_method(
258 &mut out,
259 fixture,
260 e2e_config,
261 function_name,
262 result_var,
263 args,
264 field_resolver,
265 result_is_simple,
266 enum_fields,
267 );
268 }
269 let _ = writeln!(out);
270 }
271
272 let _ = writeln!(out, "}}");
273 out
274}
275
276struct SwiftTestClientRenderer;
283
284impl client::TestClientRenderer for SwiftTestClientRenderer {
285 fn language_name(&self) -> &'static str {
286 "swift"
287 }
288
289 fn sanitize_test_name(&self, id: &str) -> String {
290 sanitize_ident(id).to_upper_camel_case()
292 }
293
294 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
300 let _ = writeln!(out, " /// {description}");
301 let _ = writeln!(out, " func test{fn_name}() throws {{");
302 if let Some(reason) = skip_reason {
303 let escaped = escape_swift(reason);
304 let _ = writeln!(out, " try XCTSkipIf(true, \"{escaped}\")");
305 }
306 }
307
308 fn render_test_close(&self, out: &mut String) {
309 let _ = writeln!(out, " }}");
310 }
311
312 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
319 let method = ctx.method.to_uppercase();
320 let fixture_path = escape_swift(ctx.path);
321
322 let _ = writeln!(
323 out,
324 " let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
325 );
326 let _ = writeln!(
327 out,
328 " var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
329 );
330 let _ = writeln!(out, " _req.httpMethod = \"{method}\"");
331
332 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
334 header_pairs.sort_by_key(|(k, _)| k.as_str());
335 for (k, v) in &header_pairs {
336 let expanded_v = expand_fixture_templates(v);
337 let ek = escape_swift(k);
338 let ev = escape_swift(&expanded_v);
339 let _ = writeln!(out, " _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
340 }
341
342 if let Some(body) = ctx.body {
344 let json_str = serde_json::to_string(body).unwrap_or_default();
345 let escaped_body = escape_swift(&json_str);
346 let _ = writeln!(out, " _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
347 let _ = writeln!(
348 out,
349 " _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
350 );
351 }
352
353 let _ = writeln!(out, " var {}: HTTPURLResponse?", ctx.response_var);
354 let _ = writeln!(out, " var _responseData: Data?");
355 let _ = writeln!(out, " let _sema = DispatchSemaphore(value: 0)");
356 let _ = writeln!(
357 out,
358 " URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
359 );
360 let _ = writeln!(out, " {} = resp as? HTTPURLResponse", ctx.response_var);
361 let _ = writeln!(out, " _responseData = data");
362 let _ = writeln!(out, " _sema.signal()");
363 let _ = writeln!(out, " }}.resume()");
364 let _ = writeln!(out, " _sema.wait()");
365 let _ = writeln!(out, " let _resp = try XCTUnwrap({})", ctx.response_var);
366 }
367
368 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
369 let _ = writeln!(out, " XCTAssertEqual(_resp.statusCode, {status})");
370 }
371
372 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
373 let lower_name = name.to_lowercase();
374 let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
375 match expected {
376 "<<present>>" => {
377 let _ = writeln!(out, " XCTAssertNotNil({header_expr})");
378 }
379 "<<absent>>" => {
380 let _ = writeln!(out, " XCTAssertNil({header_expr})");
381 }
382 "<<uuid>>" => {
383 let _ = writeln!(out, " let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
384 let _ = writeln!(
385 out,
386 " 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))"
387 );
388 }
389 exact => {
390 let escaped = escape_swift(exact);
391 let _ = writeln!(out, " XCTAssertEqual({header_expr}, \"{escaped}\")");
392 }
393 }
394 }
395
396 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
397 if let serde_json::Value::String(s) = expected {
398 let escaped = escape_swift(s);
399 let _ = writeln!(
400 out,
401 " let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
402 );
403 let _ = writeln!(
404 out,
405 " XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
406 );
407 } else {
408 let json_str = serde_json::to_string(expected).unwrap_or_default();
409 let escaped = escape_swift(&json_str);
410 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
411 let _ = writeln!(
412 out,
413 " let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
414 );
415 let _ = writeln!(
416 out,
417 " let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
418 );
419 let _ = writeln!(
420 out,
421 " XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
422 );
423 }
424 }
425
426 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
427 if let Some(obj) = expected.as_object() {
428 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
429 let _ = writeln!(
430 out,
431 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
432 );
433 for (key, val) in obj {
434 let escaped_key = escape_swift(key);
435 let swift_val = json_to_swift(val);
436 let _ = writeln!(
437 out,
438 " XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
439 );
440 }
441 }
442 }
443
444 fn render_assert_validation_errors(
445 &self,
446 out: &mut String,
447 _response_var: &str,
448 errors: &[ValidationErrorExpectation],
449 ) {
450 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
451 let _ = writeln!(
452 out,
453 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
454 );
455 let _ = writeln!(
456 out,
457 " let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
458 );
459 for ve in errors {
460 let escaped_msg = escape_swift(&ve.msg);
461 let _ = writeln!(
462 out,
463 " XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
464 );
465 }
466 }
467}
468
469fn render_http_test_method(out: &mut String, fixture: &Fixture) {
474 let Some(http) = &fixture.http else {
475 return;
476 };
477
478 if http.expected_response.status_code == 101 {
480 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
481 let description = fixture.description.replace('"', "\\\"");
482 let _ = writeln!(out, " /// {description}");
483 let _ = writeln!(out, " func test{method_name}() throws {{");
484 let _ = writeln!(
485 out,
486 " try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
487 );
488 let _ = writeln!(out, " }}");
489 return;
490 }
491
492 client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
493}
494
495#[allow(clippy::too_many_arguments)]
500fn render_test_method(
501 out: &mut String,
502 fixture: &Fixture,
503 e2e_config: &E2eConfig,
504 _function_name: &str,
505 _result_var: &str,
506 _args: &[crate::config::ArgMapping],
507 field_resolver: &FieldResolver,
508 result_is_simple: bool,
509 enum_fields: &HashSet<String>,
510) {
511 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
513 let lang = "swift";
514 let call_overrides = call_config.overrides.get(lang);
515 let function_name = call_overrides
516 .and_then(|o| o.function.as_ref())
517 .cloned()
518 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
519 let result_var = &call_config.result_var;
520 let args = &call_config.args;
521
522 let method_name = fixture.id.to_upper_camel_case();
523 let description = &fixture.description;
524 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
525 let is_async = call_config.r#async;
526
527 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
528
529 let qualified_function_name = format!("RustBridge.{function_name}");
531
532 if is_async {
533 let _ = writeln!(out, " func test{method_name}() async throws {{");
534 } else {
535 let _ = writeln!(out, " func test{method_name}() throws {{");
536 }
537 let _ = writeln!(out, " // {description}");
538
539 for line in &setup_lines {
540 let _ = writeln!(out, " {line}");
541 }
542
543 if expects_error {
544 if is_async {
545 let _ = writeln!(out, " do {{");
550 let _ = writeln!(out, " _ = try await {qualified_function_name}({args_str})");
551 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
552 let _ = writeln!(out, " }} catch {{");
553 let _ = writeln!(out, " // success");
554 let _ = writeln!(out, " }}");
555 } else {
556 let _ = writeln!(
557 out,
558 " XCTAssertThrowsError(try {qualified_function_name}({args_str}))"
559 );
560 }
561 let _ = writeln!(out, " }}");
562 return;
563 }
564
565 if is_async {
566 let _ = writeln!(
567 out,
568 " let {result_var} = try await {qualified_function_name}({args_str})"
569 );
570 } else {
571 let _ = writeln!(
572 out,
573 " let {result_var} = try {qualified_function_name}({args_str})"
574 );
575 }
576
577 for assertion in &fixture.assertions {
578 render_assertion(
579 out,
580 assertion,
581 result_var,
582 field_resolver,
583 result_is_simple,
584 enum_fields,
585 );
586 }
587
588 let _ = writeln!(out, " }}");
589}
590
591fn build_args_and_setup(
593 input: &serde_json::Value,
594 args: &[crate::config::ArgMapping],
595 fixture_id: &str,
596) -> (Vec<String>, String) {
597 if args.is_empty() {
598 return (Vec::new(), String::new());
599 }
600
601 let mut setup_lines: Vec<String> = Vec::new();
602 let mut parts: Vec<String> = Vec::new();
603
604 for arg in args {
605 if arg.arg_type == "mock_url" {
606 setup_lines.push(format!(
607 "let {} = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"",
608 arg.name,
609 ));
610 parts.push(arg.name.clone());
611 continue;
612 }
613
614 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
615 let val = input.get(field);
616 match val {
617 None | Some(serde_json::Value::Null) if arg.optional => {
618 continue;
619 }
620 None | Some(serde_json::Value::Null) => {
621 let default_val = match arg.arg_type.as_str() {
622 "string" => "\"\"".to_string(),
623 "int" | "integer" => "0".to_string(),
624 "float" | "number" => "0.0".to_string(),
625 "bool" | "boolean" => "false".to_string(),
626 _ => "nil".to_string(),
627 };
628 parts.push(default_val);
629 }
630 Some(v) => {
631 parts.push(json_to_swift(v));
632 }
633 }
634 }
635
636 (setup_lines, parts.join(", "))
637}
638
639fn render_assertion(
640 out: &mut String,
641 assertion: &Assertion,
642 result_var: &str,
643 field_resolver: &FieldResolver,
644 result_is_simple: bool,
645 enum_fields: &HashSet<String>,
646) {
647 if let Some(f) = &assertion.field {
649 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
650 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
651 return;
652 }
653 }
654
655 let field_is_enum = assertion
657 .field
658 .as_deref()
659 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
660
661 let field_expr = if result_is_simple {
662 result_var.to_string()
663 } else {
664 match &assertion.field {
665 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
666 _ => result_var.to_string(),
667 }
668 };
669
670 let string_expr = if field_is_enum {
672 format!("{field_expr}.rawValue")
673 } else {
674 field_expr.clone()
675 };
676
677 match assertion.assertion_type.as_str() {
678 "equals" => {
679 if let Some(expected) = &assertion.value {
680 let swift_val = json_to_swift(expected);
681 if expected.is_string() {
682 let field_is_optional = assertion
684 .field
685 .as_deref()
686 .is_some_and(|f| field_resolver.is_optional(f));
687 let trim_expr = if field_is_optional {
688 format!("({field_expr} ?? \"\").trimmingCharacters(in: .whitespaces)")
689 } else {
690 format!("{string_expr}.trimmingCharacters(in: .whitespaces)")
691 };
692 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
693 } else {
694 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
695 }
696 }
697 }
698 "contains" => {
699 if let Some(expected) = &assertion.value {
700 let swift_val = json_to_swift(expected);
701 let _ = writeln!(
702 out,
703 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
704 );
705 }
706 }
707 "contains_all" => {
708 if let Some(values) = &assertion.values {
709 for val in values {
710 let swift_val = json_to_swift(val);
711 let _ = writeln!(
712 out,
713 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
714 );
715 }
716 }
717 }
718 "not_contains" => {
719 if let Some(expected) = &assertion.value {
720 let swift_val = json_to_swift(expected);
721 let _ = writeln!(
722 out,
723 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
724 );
725 }
726 }
727 "not_empty" => {
728 let _ = writeln!(
729 out,
730 " XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
731 );
732 }
733 "is_empty" => {
734 let _ = writeln!(
735 out,
736 " XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
737 );
738 }
739 "contains_any" => {
740 if let Some(values) = &assertion.values {
741 let checks: Vec<String> = values
742 .iter()
743 .map(|v| {
744 let swift_val = json_to_swift(v);
745 format!("{string_expr}.contains({swift_val})")
746 })
747 .collect();
748 let joined = checks.join(" || ");
749 let _ = writeln!(
750 out,
751 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
752 );
753 }
754 }
755 "greater_than" => {
756 if let Some(val) = &assertion.value {
757 let swift_val = json_to_swift(val);
758 let _ = writeln!(out, " XCTAssertGreaterThan({field_expr}, {swift_val})");
759 }
760 }
761 "less_than" => {
762 if let Some(val) = &assertion.value {
763 let swift_val = json_to_swift(val);
764 let _ = writeln!(out, " XCTAssertLessThan({field_expr}, {swift_val})");
765 }
766 }
767 "greater_than_or_equal" => {
768 if let Some(val) = &assertion.value {
769 let swift_val = json_to_swift(val);
770 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({field_expr}, {swift_val})");
771 }
772 }
773 "less_than_or_equal" => {
774 if let Some(val) = &assertion.value {
775 let swift_val = json_to_swift(val);
776 let _ = writeln!(out, " XCTAssertLessThanOrEqual({field_expr}, {swift_val})");
777 }
778 }
779 "starts_with" => {
780 if let Some(expected) = &assertion.value {
781 let swift_val = json_to_swift(expected);
782 let _ = writeln!(
783 out,
784 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
785 );
786 }
787 }
788 "ends_with" => {
789 if let Some(expected) = &assertion.value {
790 let swift_val = json_to_swift(expected);
791 let _ = writeln!(
792 out,
793 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
794 );
795 }
796 }
797 "min_length" => {
798 if let Some(val) = &assertion.value {
799 if let Some(n) = val.as_u64() {
800 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({field_expr}.count, {n})");
801 }
802 }
803 }
804 "max_length" => {
805 if let Some(val) = &assertion.value {
806 if let Some(n) = val.as_u64() {
807 let _ = writeln!(out, " XCTAssertLessThanOrEqual({field_expr}.count, {n})");
808 }
809 }
810 }
811 "count_min" => {
812 if let Some(val) = &assertion.value {
813 if let Some(n) = val.as_u64() {
814 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({field_expr}.count, {n})");
815 }
816 }
817 }
818 "count_equals" => {
819 if let Some(val) = &assertion.value {
820 if let Some(n) = val.as_u64() {
821 let _ = writeln!(out, " XCTAssertEqual({field_expr}.count, {n})");
822 }
823 }
824 }
825 "is_true" => {
826 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
827 }
828 "is_false" => {
829 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
830 }
831 "matches_regex" => {
832 if let Some(expected) = &assertion.value {
833 let swift_val = json_to_swift(expected);
834 let _ = writeln!(
835 out,
836 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
837 );
838 }
839 }
840 "not_error" => {
841 }
843 "error" => {
844 }
846 "method_result" => {
847 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
848 }
849 other => {
850 panic!("Swift e2e generator: unsupported assertion type: {other}");
851 }
852 }
853}
854
855fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
861 let mut components = std::path::PathBuf::new();
862 for component in path.components() {
863 match component {
864 std::path::Component::ParentDir => {
865 if !components.as_os_str().is_empty() {
868 components.pop();
869 } else {
870 components.push(component);
871 }
872 }
873 std::path::Component::CurDir => {}
874 other => components.push(other),
875 }
876 }
877 components
878}
879
880fn json_to_swift(value: &serde_json::Value) -> String {
882 match value {
883 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
884 serde_json::Value::Bool(b) => b.to_string(),
885 serde_json::Value::Number(n) => n.to_string(),
886 serde_json::Value::Null => "nil".to_string(),
887 serde_json::Value::Array(arr) => {
888 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
889 format!("[{}]", items.join(", "))
890 }
891 serde_json::Value::Object(_) => {
892 let json_str = serde_json::to_string(value).unwrap_or_default();
893 format!("\"{}\"", escape_swift(&json_str))
894 }
895 }
896}
897
898fn escape_swift(s: &str) -> String {
900 escape_swift_str(s)
901}