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