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        // Generate Package.swift.
70        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        // One test file per fixture group.
84        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
128// ---------------------------------------------------------------------------
129// Rendering
130// ---------------------------------------------------------------------------
131
132fn 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    // SwiftPM platform enums use the major version only (.v13, .v14, ...);
152    // strip patch components to match the scaffold's `Package.swift`.
153    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    // Resolve per-fixture call config.
231    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            // XCTAssertThrowsError is a synchronous macro; for async-throwing
262            // functions use a do/catch with explicit XCTFail to enforce that
263            // the throw actually happens. `await XCTAssertThrowsError(...)` is
264            // not valid Swift — it evaluates `await` against a non-async expr.
265            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
298/// Build setup lines and the argument list for the function call.
299fn 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    // Skip assertions on fields that don't exist on the result type.
355    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    // Determine if this field is an enum type.
363    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    // For enum fields, use .rawValue to get the string value.
378    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            // Already handled by the call succeeding without exception.
542        }
543        "error" => {
544            // Handled at the test method level.
545        }
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
555/// Convert a `serde_json::Value` to a Swift literal string.
556fn 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
573/// Escape a string for embedding in a Swift double-quoted string literal.
574fn escape_swift(s: &str) -> String {
575    escape_swift_str(s)
576}