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 {
83 path: output_base.join("Package.swift"),
84 content: render_package_swift(module_name, ®istry_url, &pkg_path, &pkg_version, e2e_config.dep_mode),
85 generated_header: false,
86 });
87
88 let tests_base = normalize_path(&output_base.join(&pkg_path));
102
103 let field_resolver = FieldResolver::new(
104 &e2e_config.fields,
105 &e2e_config.fields_optional,
106 &e2e_config.result_fields,
107 &e2e_config.fields_array,
108 );
109
110 for group in groups {
112 let active: Vec<&Fixture> = group
113 .fixtures
114 .iter()
115 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
116 .collect();
117
118 if active.is_empty() {
119 continue;
120 }
121
122 let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
123 let filename = format!("{class_name}.swift");
124 let content = render_test_file(
125 &group.category,
126 &active,
127 e2e_config,
128 module_name,
129 &class_name,
130 &function_name,
131 result_var,
132 &e2e_config.call.args,
133 &field_resolver,
134 result_is_simple,
135 &e2e_config.fields_enum,
136 );
137 files.push(GeneratedFile {
138 path: tests_base
139 .join("Tests")
140 .join(format!("{module_name}Tests"))
141 .join(filename),
142 content,
143 generated_header: true,
144 });
145 }
146
147 Ok(files)
148 }
149
150 fn language_name(&self) -> &'static str {
151 "swift"
152 }
153}
154
155fn render_package_swift(
160 module_name: &str,
161 registry_url: &str,
162 pkg_path: &str,
163 pkg_version: &str,
164 dep_mode: crate::config::DependencyMode,
165) -> String {
166 let min_macos = toolchain::SWIFT_MIN_MACOS;
167
168 let (dep_block, product_dep) = match dep_mode {
172 crate::config::DependencyMode::Registry => {
173 let dep = format!(r#" .package(url: "{registry_url}", from: "{pkg_version}")"#);
174 let pkg_id = registry_url
175 .trim_end_matches('/')
176 .trim_end_matches(".git")
177 .split('/')
178 .next_back()
179 .unwrap_or(module_name);
180 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
181 (dep, prod)
182 }
183 crate::config::DependencyMode::Local => {
184 let dep = format!(r#" .package(path: "{pkg_path}")"#);
185 let pkg_id = pkg_path
186 .trim_end_matches('/')
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 };
194 let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
197 format!(
198 r#"// swift-tools-version: 6.0
199import PackageDescription
200
201let package = Package(
202 name: "E2eSwift",
203 platforms: [
204 .macOS(.v{min_macos_major}),
205 ],
206 dependencies: [
207{dep_block},
208 ],
209 targets: [
210 .testTarget(
211 name: "{module_name}Tests",
212 dependencies: [{product_dep}]
213 ),
214 ]
215)
216"#
217 )
218}
219
220#[allow(clippy::too_many_arguments)]
221fn render_test_file(
222 category: &str,
223 fixtures: &[&Fixture],
224 e2e_config: &E2eConfig,
225 module_name: &str,
226 class_name: &str,
227 function_name: &str,
228 result_var: &str,
229 args: &[crate::config::ArgMapping],
230 field_resolver: &FieldResolver,
231 result_is_simple: bool,
232 enum_fields: &HashSet<String>,
233) -> String {
234 let mut out = String::new();
235 out.push_str(&hash::header(CommentStyle::DoubleSlash));
236 let _ = writeln!(out, "import XCTest");
237 let _ = writeln!(out, "import {module_name}");
238 let _ = writeln!(out);
239 let _ = writeln!(out, "/// E2e tests for category: {category}.");
240 let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
241
242 for fixture in fixtures {
243 render_test_method(
244 &mut out,
245 fixture,
246 e2e_config,
247 function_name,
248 result_var,
249 args,
250 field_resolver,
251 result_is_simple,
252 enum_fields,
253 );
254 let _ = writeln!(out);
255 }
256
257 let _ = writeln!(out, "}}");
258 out
259}
260
261#[allow(clippy::too_many_arguments)]
262fn render_test_method(
263 out: &mut String,
264 fixture: &Fixture,
265 e2e_config: &E2eConfig,
266 _function_name: &str,
267 _result_var: &str,
268 _args: &[crate::config::ArgMapping],
269 field_resolver: &FieldResolver,
270 result_is_simple: bool,
271 enum_fields: &HashSet<String>,
272) {
273 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
275 let lang = "swift";
276 let call_overrides = call_config.overrides.get(lang);
277 let function_name = call_overrides
278 .and_then(|o| o.function.as_ref())
279 .cloned()
280 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
281 let result_var = &call_config.result_var;
282 let args = &call_config.args;
283
284 let method_name = fixture.id.to_upper_camel_case();
285 let description = &fixture.description;
286 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
287 let is_async = call_config.r#async;
288
289 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
290
291 if is_async {
292 let _ = writeln!(out, " func test{method_name}() async throws {{");
293 } else {
294 let _ = writeln!(out, " func test{method_name}() throws {{");
295 }
296 let _ = writeln!(out, " // {description}");
297
298 for line in &setup_lines {
299 let _ = writeln!(out, " {line}");
300 }
301
302 if expects_error {
303 if is_async {
304 let _ = writeln!(out, " do {{");
309 let _ = writeln!(out, " _ = try await {function_name}({args_str})");
310 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
311 let _ = writeln!(out, " }} catch {{");
312 let _ = writeln!(out, " // success");
313 let _ = writeln!(out, " }}");
314 } else {
315 let _ = writeln!(out, " XCTAssertThrowsError(try {function_name}({args_str}))");
316 }
317 let _ = writeln!(out, " }}");
318 return;
319 }
320
321 if is_async {
322 let _ = writeln!(out, " let {result_var} = try await {function_name}({args_str})");
323 } else {
324 let _ = writeln!(out, " let {result_var} = try {function_name}({args_str})");
325 }
326
327 for assertion in &fixture.assertions {
328 render_assertion(
329 out,
330 assertion,
331 result_var,
332 field_resolver,
333 result_is_simple,
334 enum_fields,
335 );
336 }
337
338 let _ = writeln!(out, " }}");
339}
340
341fn build_args_and_setup(
343 input: &serde_json::Value,
344 args: &[crate::config::ArgMapping],
345 fixture_id: &str,
346) -> (Vec<String>, String) {
347 if args.is_empty() {
348 return (Vec::new(), String::new());
349 }
350
351 let mut setup_lines: Vec<String> = Vec::new();
352 let mut parts: Vec<String> = Vec::new();
353
354 for arg in args {
355 if arg.arg_type == "mock_url" {
356 setup_lines.push(format!(
357 "let {} = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"",
358 arg.name,
359 ));
360 parts.push(arg.name.clone());
361 continue;
362 }
363
364 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
365 let val = input.get(field);
366 match val {
367 None | Some(serde_json::Value::Null) if arg.optional => {
368 continue;
369 }
370 None | Some(serde_json::Value::Null) => {
371 let default_val = match arg.arg_type.as_str() {
372 "string" => "\"\"".to_string(),
373 "int" | "integer" => "0".to_string(),
374 "float" | "number" => "0.0".to_string(),
375 "bool" | "boolean" => "false".to_string(),
376 _ => "nil".to_string(),
377 };
378 parts.push(default_val);
379 }
380 Some(v) => {
381 parts.push(json_to_swift(v));
382 }
383 }
384 }
385
386 (setup_lines, parts.join(", "))
387}
388
389fn render_assertion(
390 out: &mut String,
391 assertion: &Assertion,
392 result_var: &str,
393 field_resolver: &FieldResolver,
394 result_is_simple: bool,
395 enum_fields: &HashSet<String>,
396) {
397 if let Some(f) = &assertion.field {
399 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
400 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
401 return;
402 }
403 }
404
405 let field_is_enum = assertion
407 .field
408 .as_deref()
409 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
410
411 let field_expr = if result_is_simple {
412 result_var.to_string()
413 } else {
414 match &assertion.field {
415 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
416 _ => result_var.to_string(),
417 }
418 };
419
420 let string_expr = if field_is_enum {
422 format!("{field_expr}.rawValue")
423 } else {
424 field_expr.clone()
425 };
426
427 match assertion.assertion_type.as_str() {
428 "equals" => {
429 if let Some(expected) = &assertion.value {
430 let swift_val = json_to_swift(expected);
431 if expected.is_string() {
432 let _ = writeln!(
433 out,
434 " XCTAssertEqual({string_expr}.trimmingCharacters(in: .whitespaces), {swift_val})"
435 );
436 } else {
437 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {swift_val})");
438 }
439 }
440 }
441 "contains" => {
442 if let Some(expected) = &assertion.value {
443 let swift_val = json_to_swift(expected);
444 let _ = writeln!(
445 out,
446 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
447 );
448 }
449 }
450 "contains_all" => {
451 if let Some(values) = &assertion.values {
452 for val in values {
453 let swift_val = json_to_swift(val);
454 let _ = writeln!(
455 out,
456 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
457 );
458 }
459 }
460 }
461 "not_contains" => {
462 if let Some(expected) = &assertion.value {
463 let swift_val = json_to_swift(expected);
464 let _ = writeln!(
465 out,
466 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
467 );
468 }
469 }
470 "not_empty" => {
471 let _ = writeln!(
472 out,
473 " XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
474 );
475 }
476 "is_empty" => {
477 let _ = writeln!(
478 out,
479 " XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
480 );
481 }
482 "contains_any" => {
483 if let Some(values) = &assertion.values {
484 let checks: Vec<String> = values
485 .iter()
486 .map(|v| {
487 let swift_val = json_to_swift(v);
488 format!("{string_expr}.contains({swift_val})")
489 })
490 .collect();
491 let joined = checks.join(" || ");
492 let _ = writeln!(
493 out,
494 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
495 );
496 }
497 }
498 "greater_than" => {
499 if let Some(val) = &assertion.value {
500 let swift_val = json_to_swift(val);
501 let _ = writeln!(out, " XCTAssertGreaterThan({field_expr}, {swift_val})");
502 }
503 }
504 "less_than" => {
505 if let Some(val) = &assertion.value {
506 let swift_val = json_to_swift(val);
507 let _ = writeln!(out, " XCTAssertLessThan({field_expr}, {swift_val})");
508 }
509 }
510 "greater_than_or_equal" => {
511 if let Some(val) = &assertion.value {
512 let swift_val = json_to_swift(val);
513 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({field_expr}, {swift_val})");
514 }
515 }
516 "less_than_or_equal" => {
517 if let Some(val) = &assertion.value {
518 let swift_val = json_to_swift(val);
519 let _ = writeln!(out, " XCTAssertLessThanOrEqual({field_expr}, {swift_val})");
520 }
521 }
522 "starts_with" => {
523 if let Some(expected) = &assertion.value {
524 let swift_val = json_to_swift(expected);
525 let _ = writeln!(
526 out,
527 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
528 );
529 }
530 }
531 "ends_with" => {
532 if let Some(expected) = &assertion.value {
533 let swift_val = json_to_swift(expected);
534 let _ = writeln!(
535 out,
536 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
537 );
538 }
539 }
540 "min_length" => {
541 if let Some(val) = &assertion.value {
542 if let Some(n) = val.as_u64() {
543 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({field_expr}.count, {n})");
544 }
545 }
546 }
547 "max_length" => {
548 if let Some(val) = &assertion.value {
549 if let Some(n) = val.as_u64() {
550 let _ = writeln!(out, " XCTAssertLessThanOrEqual({field_expr}.count, {n})");
551 }
552 }
553 }
554 "count_min" => {
555 if let Some(val) = &assertion.value {
556 if let Some(n) = val.as_u64() {
557 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({field_expr}.count, {n})");
558 }
559 }
560 }
561 "count_equals" => {
562 if let Some(val) = &assertion.value {
563 if let Some(n) = val.as_u64() {
564 let _ = writeln!(out, " XCTAssertEqual({field_expr}.count, {n})");
565 }
566 }
567 }
568 "is_true" => {
569 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
570 }
571 "is_false" => {
572 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
573 }
574 "matches_regex" => {
575 if let Some(expected) = &assertion.value {
576 let swift_val = json_to_swift(expected);
577 let _ = writeln!(
578 out,
579 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
580 );
581 }
582 }
583 "not_error" => {
584 }
586 "error" => {
587 }
589 "method_result" => {
590 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
591 }
592 other => {
593 panic!("Swift e2e generator: unsupported assertion type: {other}");
594 }
595 }
596}
597
598fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
604 let mut components = std::path::PathBuf::new();
605 for component in path.components() {
606 match component {
607 std::path::Component::ParentDir => {
608 if !components.as_os_str().is_empty() {
611 components.pop();
612 } else {
613 components.push(component);
614 }
615 }
616 std::path::Component::CurDir => {}
617 other => components.push(other),
618 }
619 }
620 components
621}
622
623fn json_to_swift(value: &serde_json::Value) -> String {
625 match value {
626 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
627 serde_json::Value::Bool(b) => b.to_string(),
628 serde_json::Value::Number(n) => n.to_string(),
629 serde_json::Value::Null => "nil".to_string(),
630 serde_json::Value::Array(arr) => {
631 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
632 format!("[{}]", items.join(", "))
633 }
634 serde_json::Value::Object(_) => {
635 let json_str = serde_json::to_string(value).unwrap_or_default();
636 format!("\"{}\"", escape_swift(&json_str))
637 }
638 }
639}
640
641fn escape_swift(s: &str) -> String {
643 escape_swift_str(s)
644}