1use crate::config::E2eConfig;
8use crate::escape::{escape_java as escape_swift_str, sanitize_filename};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions::toolchain;
15use anyhow::Result;
16use heck::{ToLowerCamelCase, ToUpperCamelCase};
17use std::collections::HashSet;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22
23pub struct SwiftE2eCodegen;
25
26impl E2eCodegen for SwiftE2eCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 alef_config: &AlefConfig,
32 ) -> Result<Vec<GeneratedFile>> {
33 let lang = self.language_name();
34 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36 let mut files = Vec::new();
37
38 let call = &e2e_config.call;
40 let overrides = call.overrides.get(lang);
41 let function_name = overrides
42 .and_then(|o| o.function.as_ref())
43 .cloned()
44 .unwrap_or_else(|| call.function.clone());
45 let result_var = &call.result_var;
46 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
47
48 let swift_pkg = e2e_config.resolve_package("swift");
50 let pkg_name = swift_pkg
51 .as_ref()
52 .and_then(|p| p.name.as_ref())
53 .cloned()
54 .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
55 let pkg_path = swift_pkg
56 .as_ref()
57 .and_then(|p| p.path.as_ref())
58 .cloned()
59 .unwrap_or_else(|| "../../packages/swift".to_string());
60 let pkg_version = swift_pkg
61 .as_ref()
62 .and_then(|p| p.version.as_ref())
63 .cloned()
64 .unwrap_or_else(|| "0.1.0".to_string());
65
66 let module_name = pkg_name.as_str();
68
69 let registry_url = alef_config
73 .try_github_repo()
74 .map(|repo| {
75 let base = repo.trim_end_matches('/').trim_end_matches(".git");
76 format!("{base}.git")
77 })
78 .unwrap_or_else(|_| format!("https://example.invalid/{module_name}.git"));
79
80 files.push(GeneratedFile {
82 path: output_base.join("Package.swift"),
83 content: render_package_swift(module_name, ®istry_url, &pkg_path, &pkg_version, e2e_config.dep_mode),
84 generated_header: false,
85 });
86
87 let field_resolver = FieldResolver::new(
88 &e2e_config.fields,
89 &e2e_config.fields_optional,
90 &e2e_config.result_fields,
91 &e2e_config.fields_array,
92 );
93
94 for group in groups {
96 let active: Vec<&Fixture> = group
97 .fixtures
98 .iter()
99 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
100 .collect();
101
102 if active.is_empty() {
103 continue;
104 }
105
106 let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
107 let filename = format!("{class_name}.swift");
108 let content = render_test_file(
109 &group.category,
110 &active,
111 e2e_config,
112 module_name,
113 &class_name,
114 &function_name,
115 result_var,
116 &e2e_config.call.args,
117 &field_resolver,
118 result_is_simple,
119 &e2e_config.fields_enum,
120 );
121 files.push(GeneratedFile {
122 path: output_base
123 .join("Tests")
124 .join(format!("{module_name}Tests"))
125 .join(filename),
126 content,
127 generated_header: true,
128 });
129 }
130
131 Ok(files)
132 }
133
134 fn language_name(&self) -> &'static str {
135 "swift"
136 }
137}
138
139fn render_package_swift(
144 module_name: &str,
145 registry_url: &str,
146 pkg_path: &str,
147 pkg_version: &str,
148 dep_mode: crate::config::DependencyMode,
149) -> String {
150 let min_macos = toolchain::SWIFT_MIN_MACOS;
151
152 let dep_block = match dep_mode {
153 crate::config::DependencyMode::Registry => {
154 format!(r#" .package(url: "{registry_url}", from: "{pkg_version}")"#)
155 }
156 crate::config::DependencyMode::Local => {
157 format!(r#" .package(path: "{pkg_path}")"#)
158 }
159 };
160 let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
163 format!(
164 r#"// swift-tools-version: 5.9
165import PackageDescription
166
167let package = Package(
168 name: "E2eSwift",
169 platforms: [
170 .macOS(.v{min_macos_major}),
171 ],
172 dependencies: [
173{dep_block},
174 ],
175 targets: [
176 .testTarget(
177 name: "{module_name}Tests",
178 dependencies: ["{module_name}"]
179 ),
180 ]
181)
182"#
183 )
184}
185
186#[allow(clippy::too_many_arguments)]
187fn render_test_file(
188 category: &str,
189 fixtures: &[&Fixture],
190 e2e_config: &E2eConfig,
191 module_name: &str,
192 class_name: &str,
193 function_name: &str,
194 result_var: &str,
195 args: &[crate::config::ArgMapping],
196 field_resolver: &FieldResolver,
197 result_is_simple: bool,
198 enum_fields: &HashSet<String>,
199) -> String {
200 let mut out = String::new();
201 out.push_str(&hash::header(CommentStyle::DoubleSlash));
202 let _ = writeln!(out, "import XCTest");
203 let _ = writeln!(out, "import {module_name}");
204 let _ = writeln!(out);
205 let _ = writeln!(out, "/// E2e tests for category: {category}.");
206 let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
207
208 for fixture in fixtures {
209 render_test_method(
210 &mut out,
211 fixture,
212 e2e_config,
213 function_name,
214 result_var,
215 args,
216 field_resolver,
217 result_is_simple,
218 enum_fields,
219 );
220 let _ = writeln!(out);
221 }
222
223 let _ = writeln!(out, "}}");
224 out
225}
226
227#[allow(clippy::too_many_arguments)]
228fn render_test_method(
229 out: &mut String,
230 fixture: &Fixture,
231 e2e_config: &E2eConfig,
232 _function_name: &str,
233 _result_var: &str,
234 _args: &[crate::config::ArgMapping],
235 field_resolver: &FieldResolver,
236 result_is_simple: bool,
237 enum_fields: &HashSet<String>,
238) {
239 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
241 let lang = "swift";
242 let call_overrides = call_config.overrides.get(lang);
243 let function_name = call_overrides
244 .and_then(|o| o.function.as_ref())
245 .cloned()
246 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
247 let result_var = &call_config.result_var;
248 let args = &call_config.args;
249
250 let method_name = fixture.id.to_upper_camel_case();
251 let description = &fixture.description;
252 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
253 let is_async = call_config.r#async;
254
255 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
256
257 if is_async {
258 let _ = writeln!(out, " func test{method_name}() async throws {{");
259 } else {
260 let _ = writeln!(out, " func test{method_name}() throws {{");
261 }
262 let _ = writeln!(out, " // {description}");
263
264 for line in &setup_lines {
265 let _ = writeln!(out, " {line}");
266 }
267
268 if expects_error {
269 if is_async {
270 let _ = writeln!(out, " do {{");
275 let _ = writeln!(out, " _ = try await {function_name}({args_str})");
276 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
277 let _ = writeln!(out, " }} catch {{");
278 let _ = writeln!(out, " // success");
279 let _ = writeln!(out, " }}");
280 } else {
281 let _ = writeln!(out, " XCTAssertThrowsError(try {function_name}({args_str}))");
282 }
283 let _ = writeln!(out, " }}");
284 return;
285 }
286
287 if is_async {
288 let _ = writeln!(out, " let {result_var} = try await {function_name}({args_str})");
289 } else {
290 let _ = writeln!(out, " let {result_var} = try {function_name}({args_str})");
291 }
292
293 for assertion in &fixture.assertions {
294 render_assertion(
295 out,
296 assertion,
297 result_var,
298 field_resolver,
299 result_is_simple,
300 enum_fields,
301 );
302 }
303
304 let _ = writeln!(out, " }}");
305}
306
307fn build_args_and_setup(
309 input: &serde_json::Value,
310 args: &[crate::config::ArgMapping],
311 fixture_id: &str,
312) -> (Vec<String>, String) {
313 if args.is_empty() {
314 return (Vec::new(), String::new());
315 }
316
317 let mut setup_lines: Vec<String> = Vec::new();
318 let mut parts: Vec<String> = Vec::new();
319
320 for arg in args {
321 if arg.arg_type == "mock_url" {
322 setup_lines.push(format!(
323 "let {} = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"",
324 arg.name,
325 ));
326 parts.push(arg.name.clone());
327 continue;
328 }
329
330 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
331 let val = input.get(field);
332 match val {
333 None | Some(serde_json::Value::Null) if arg.optional => {
334 continue;
335 }
336 None | Some(serde_json::Value::Null) => {
337 let default_val = match arg.arg_type.as_str() {
338 "string" => "\"\"".to_string(),
339 "int" | "integer" => "0".to_string(),
340 "float" | "number" => "0.0".to_string(),
341 "bool" | "boolean" => "false".to_string(),
342 _ => "nil".to_string(),
343 };
344 parts.push(default_val);
345 }
346 Some(v) => {
347 parts.push(json_to_swift(v));
348 }
349 }
350 }
351
352 (setup_lines, parts.join(", "))
353}
354
355fn render_assertion(
356 out: &mut String,
357 assertion: &Assertion,
358 result_var: &str,
359 field_resolver: &FieldResolver,
360 result_is_simple: bool,
361 enum_fields: &HashSet<String>,
362) {
363 if let Some(f) = &assertion.field {
365 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
366 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
367 return;
368 }
369 }
370
371 let field_is_enum = assertion
373 .field
374 .as_deref()
375 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
376
377 let field_expr = if result_is_simple {
378 result_var.to_string()
379 } else {
380 match &assertion.field {
381 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
382 _ => result_var.to_string(),
383 }
384 };
385
386 let string_expr = if field_is_enum {
388 format!("{field_expr}.rawValue")
389 } else {
390 field_expr.clone()
391 };
392
393 match assertion.assertion_type.as_str() {
394 "equals" => {
395 if let Some(expected) = &assertion.value {
396 let swift_val = json_to_swift(expected);
397 if expected.is_string() {
398 let _ = writeln!(
399 out,
400 " XCTAssertEqual({string_expr}.trimmingCharacters(in: .whitespaces), {swift_val})"
401 );
402 } else {
403 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
404 }
405 }
406 }
407 "contains" => {
408 if let Some(expected) = &assertion.value {
409 let swift_val = json_to_swift(expected);
410 let _ = writeln!(
411 out,
412 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
413 );
414 }
415 }
416 "contains_all" => {
417 if let Some(values) = &assertion.values {
418 for val in values {
419 let swift_val = json_to_swift(val);
420 let _ = writeln!(
421 out,
422 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
423 );
424 }
425 }
426 }
427 "not_contains" => {
428 if let Some(expected) = &assertion.value {
429 let swift_val = json_to_swift(expected);
430 let _ = writeln!(
431 out,
432 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
433 );
434 }
435 }
436 "not_empty" => {
437 let _ = writeln!(
438 out,
439 " XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
440 );
441 }
442 "is_empty" => {
443 let _ = writeln!(
444 out,
445 " XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
446 );
447 }
448 "contains_any" => {
449 if let Some(values) = &assertion.values {
450 let checks: Vec<String> = values
451 .iter()
452 .map(|v| {
453 let swift_val = json_to_swift(v);
454 format!("{string_expr}.contains({swift_val})")
455 })
456 .collect();
457 let joined = checks.join(" || ");
458 let _ = writeln!(
459 out,
460 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
461 );
462 }
463 }
464 "greater_than" => {
465 if let Some(val) = &assertion.value {
466 let swift_val = json_to_swift(val);
467 let _ = writeln!(out, " XCTAssertGreaterThan({field_expr}, {swift_val})");
468 }
469 }
470 "less_than" => {
471 if let Some(val) = &assertion.value {
472 let swift_val = json_to_swift(val);
473 let _ = writeln!(out, " XCTAssertLessThan({field_expr}, {swift_val})");
474 }
475 }
476 "greater_than_or_equal" => {
477 if let Some(val) = &assertion.value {
478 let swift_val = json_to_swift(val);
479 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({field_expr}, {swift_val})");
480 }
481 }
482 "less_than_or_equal" => {
483 if let Some(val) = &assertion.value {
484 let swift_val = json_to_swift(val);
485 let _ = writeln!(out, " XCTAssertLessThanOrEqual({field_expr}, {swift_val})");
486 }
487 }
488 "starts_with" => {
489 if let Some(expected) = &assertion.value {
490 let swift_val = json_to_swift(expected);
491 let _ = writeln!(
492 out,
493 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
494 );
495 }
496 }
497 "ends_with" => {
498 if let Some(expected) = &assertion.value {
499 let swift_val = json_to_swift(expected);
500 let _ = writeln!(
501 out,
502 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
503 );
504 }
505 }
506 "min_length" => {
507 if let Some(val) = &assertion.value {
508 if let Some(n) = val.as_u64() {
509 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({field_expr}.count, {n})");
510 }
511 }
512 }
513 "max_length" => {
514 if let Some(val) = &assertion.value {
515 if let Some(n) = val.as_u64() {
516 let _ = writeln!(out, " XCTAssertLessThanOrEqual({field_expr}.count, {n})");
517 }
518 }
519 }
520 "count_min" => {
521 if let Some(val) = &assertion.value {
522 if let Some(n) = val.as_u64() {
523 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({field_expr}.count, {n})");
524 }
525 }
526 }
527 "count_equals" => {
528 if let Some(val) = &assertion.value {
529 if let Some(n) = val.as_u64() {
530 let _ = writeln!(out, " XCTAssertEqual({field_expr}.count, {n})");
531 }
532 }
533 }
534 "is_true" => {
535 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
536 }
537 "is_false" => {
538 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
539 }
540 "matches_regex" => {
541 if let Some(expected) = &assertion.value {
542 let swift_val = json_to_swift(expected);
543 let _ = writeln!(
544 out,
545 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
546 );
547 }
548 }
549 "not_error" => {
550 }
552 "error" => {
553 }
555 "method_result" => {
556 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
557 }
558 other => {
559 panic!("Swift e2e generator: unsupported assertion type: {other}");
560 }
561 }
562}
563
564fn json_to_swift(value: &serde_json::Value) -> String {
566 match value {
567 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
568 serde_json::Value::Bool(b) => b.to_string(),
569 serde_json::Value::Number(n) => n.to_string(),
570 serde_json::Value::Null => "nil".to_string(),
571 serde_json::Value::Array(arr) => {
572 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
573 format!("[{}]", items.join(", "))
574 }
575 serde_json::Value::Object(_) => {
576 let json_str = serde_json::to_string(value).unwrap_or_default();
577 format!("\"{}\"", escape_swift(&json_str))
578 }
579 }
580}
581
582fn escape_swift(s: &str) -> String {
584 escape_swift_str(s)
585}