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 (kept for tooling/CI reference but not used
81        // for running tests — see note below).
82        files.push(GeneratedFile {
83            path: output_base.join("Package.swift"),
84            content: render_package_swift(module_name, &registry_url, &pkg_path, &pkg_version, e2e_config.dep_mode),
85            generated_header: false,
86        });
87
88        // Swift e2e tests are written into the *packages/swift* package rather
89        // than into the separate e2e/swift package.  SwiftPM 6.0 forbids local
90        // `.package(path:)` references between packages inside the same git
91        // repository, so a standalone e2e/swift package cannot depend on
92        // packages/swift.  Placing the test files directly inside
93        // packages/swift/Tests/<Module>Tests/ sidesteps the restriction: the
94        // tests are part of the same SwiftPM package that defines the library
95        // target, so no inter-package dependency is needed.
96        //
97        // `pkg_path` is expressed relative to the e2e/<lang> directory (e.g.
98        // "../../packages/swift").  Joining it onto `output_base` and
99        // normalising collapses the traversals to the actual project-root-
100        // relative path (e.g. "packages/swift").
101        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        // One test file per fixture group.
111        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
155// ---------------------------------------------------------------------------
156// Rendering
157// ---------------------------------------------------------------------------
158
159fn 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    // For local deps SwiftPM identity = last path component (e.g. "../../packages/swift" → "swift").
169    // For registry deps identity is inferred from the URL.
170    // Use explicit .product(name:package:) to avoid ambiguity under tools-version 6.0.
171    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    // SwiftPM platform enums use the major version only (.v13, .v14, ...);
195    // strip patch components to match the scaffold's `Package.swift`.
196    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    // Resolve per-fixture call config.
274    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            // XCTAssertThrowsError is a synchronous macro; for async-throwing
305            // functions use a do/catch with explicit XCTFail to enforce that
306            // the throw actually happens. `await XCTAssertThrowsError(...)` is
307            // not valid Swift — it evaluates `await` against a non-async expr.
308            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
341/// Build setup lines and the argument list for the function call.
342fn 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    // Skip assertions on fields that don't exist on the result type.
398    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    // Determine if this field is an enum type.
406    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    // For enum fields, use .rawValue to get the string value.
421    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            // Already handled by the call succeeding without exception.
585        }
586        "error" => {
587            // Handled at the test method level.
588        }
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
598/// Normalise a path by resolving `..` components without hitting the filesystem.
599///
600/// This mirrors what `std::fs::canonicalize` does but works on paths that do
601/// not yet exist on disk (generated-file paths).  Only `..` traversals are
602/// collapsed; `.` components are dropped; nothing else is changed.
603fn 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                // Pop the last pushed component if there is one that isn't
609                // already a `..` (avoids over-collapsing `../../foo`).
610                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
623/// Convert a `serde_json::Value` to a Swift literal string.
624fn 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
641/// Escape a string for embedding in a Swift double-quoted string literal.
642fn escape_swift(s: &str) -> String {
643    escape_swift_str(s)
644}