Skip to main content

alef_e2e/codegen/
swift.rs

1//! Swift e2e test generator using XCTest.
2//!
3//! Generates `Tests/<Module>Tests/<FixtureId>Tests.swift` files from JSON
4//! fixtures (one file per fixture group, mirroring the Kotlin per-test-class
5//! style) and a `Package.swift` at the e2e package root.
6
7use 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
23/// Swift e2e code generator.
24pub 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        // Resolve call config with overrides.
39        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        // Resolve package config.
49        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        // The Swift module name: UpperCamelCase of the package name.
67        let module_name = pkg_name.as_str();
68
69        // Resolve the registry URL: derive from the configured repository when
70        // available (with a `.git` suffix per SwiftPM convention). Falls back
71        // to a vendor-neutral placeholder when no repo is configured.
72        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        // Generate Package.swift.
81        files.push(GeneratedFile {
82            path: output_base.join("Package.swift"),
83            content: render_package_swift(module_name, &registry_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        // One test file per fixture group.
95        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
139// ---------------------------------------------------------------------------
140// Rendering
141// ---------------------------------------------------------------------------
142
143fn 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    // SwiftPM platform enums use the major version only (.v13, .v14, ...);
161    // strip patch components to match the scaffold's `Package.swift`.
162    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    // Resolve per-fixture call config.
240    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            // XCTAssertThrowsError is a synchronous macro; for async-throwing
271            // functions use a do/catch with explicit XCTFail to enforce that
272            // the throw actually happens. `await XCTAssertThrowsError(...)` is
273            // not valid Swift — it evaluates `await` against a non-async expr.
274            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
307/// Build setup lines and the argument list for the function call.
308fn 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    // Skip assertions on fields that don't exist on the result type.
364    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    // Determine if this field is an enum type.
372    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    // For enum fields, use .rawValue to get the string value.
387    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            // Already handled by the call succeeding without exception.
551        }
552        "error" => {
553            // Handled at the test method level.
554        }
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
564/// Convert a `serde_json::Value` to a Swift literal string.
565fn 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
582/// Escape a string for embedding in a Swift double-quoted string literal.
583fn escape_swift(s: &str) -> String {
584    escape_swift_str(s)
585}