Skip to main content

alef_e2e/codegen/
swift.rs

1//! Swift e2e test generator using XCTest.
2//!
3//! Generates test files for the swift package in `packages/swift/Tests/<Module>Tests/`.
4//!
5//! IMPORTANT: Due to SwiftPM 6.0 limitations (forbids inter-package `.package(path:)`
6//! references within a monorepo), generated test files are placed directly inside
7//! the `packages/swift` package (not in a separate `e2e/swift` package). This allows
8//! tests to depend on the library target without an explicit package dependency.
9//!
10//! The generated `Package.swift` is placed in `e2e/swift/` for documentation and CI
11//! reference but is NOT used for running tests — tests are run from the
12//! `packages/swift/` directory using `swift test`.
13
14use crate::config::E2eConfig;
15use crate::escape::{escape_java as escape_swift_str, expand_fixture_templates, sanitize_filename, sanitize_ident};
16use crate::field_access::FieldResolver;
17use crate::fixture::{Assertion, Fixture, FixtureGroup, ValidationErrorExpectation};
18use alef_core::backend::GeneratedFile;
19use alef_core::config::ResolvedCrateConfig;
20use alef_core::hash::{self, CommentStyle};
21use alef_core::template_versions::toolchain;
22use anyhow::Result;
23use heck::{ToLowerCamelCase, ToUpperCamelCase};
24use std::collections::HashSet;
25use std::fmt::Write as FmtWrite;
26use std::path::PathBuf;
27
28use super::E2eCodegen;
29use super::client;
30
31/// Swift e2e code generator.
32pub struct SwiftE2eCodegen;
33
34impl E2eCodegen for SwiftE2eCodegen {
35    fn generate(
36        &self,
37        groups: &[FixtureGroup],
38        e2e_config: &E2eConfig,
39        config: &ResolvedCrateConfig,
40    ) -> Result<Vec<GeneratedFile>> {
41        let lang = self.language_name();
42        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
43
44        let mut files = Vec::new();
45
46        // Resolve call config with overrides.
47        let call = &e2e_config.call;
48        let overrides = call.overrides.get(lang);
49        let function_name = overrides
50            .and_then(|o| o.function.as_ref())
51            .cloned()
52            .unwrap_or_else(|| call.function.clone());
53        let result_var = &call.result_var;
54        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
55
56        // Resolve package config.
57        let swift_pkg = e2e_config.resolve_package("swift");
58        let pkg_name = swift_pkg
59            .as_ref()
60            .and_then(|p| p.name.as_ref())
61            .cloned()
62            .unwrap_or_else(|| config.name.to_upper_camel_case());
63        let pkg_path = swift_pkg
64            .as_ref()
65            .and_then(|p| p.path.as_ref())
66            .cloned()
67            .unwrap_or_else(|| "../../packages/swift".to_string());
68        let pkg_version = swift_pkg
69            .as_ref()
70            .and_then(|p| p.version.as_ref())
71            .cloned()
72            .or_else(|| config.resolved_version())
73            .unwrap_or_else(|| "0.1.0".to_string());
74
75        // The Swift module name: UpperCamelCase of the package name.
76        let module_name = pkg_name.as_str();
77
78        // Resolve the registry URL: derive from the configured repository when
79        // available (with a `.git` suffix per SwiftPM convention). Falls back
80        // to a vendor-neutral placeholder when no repo is configured.
81        let registry_url = config
82            .try_github_repo()
83            .map(|repo| {
84                let base = repo.trim_end_matches('/').trim_end_matches(".git");
85                format!("{base}.git")
86            })
87            .unwrap_or_else(|_| format!("https://example.invalid/{module_name}.git"));
88
89        // Generate Package.swift (kept for tooling/CI reference but not used
90        // for running tests — see note below).
91        files.push(GeneratedFile {
92            path: output_base.join("Package.swift"),
93            content: render_package_swift(module_name, &registry_url, &pkg_path, &pkg_version, e2e_config.dep_mode),
94            generated_header: false,
95        });
96
97        // Swift e2e tests are written into the *packages/swift* package rather
98        // than into the separate e2e/swift package.  SwiftPM 6.0 forbids local
99        // `.package(path:)` references between packages inside the same git
100        // repository, so a standalone e2e/swift package cannot depend on
101        // packages/swift.  Placing the test files directly inside
102        // packages/swift/Tests/<Module>Tests/ sidesteps the restriction: the
103        // tests are part of the same SwiftPM package that defines the library
104        // target, so no inter-package dependency is needed.
105        //
106        // `pkg_path` is expressed relative to the e2e/<lang> directory (e.g.
107        // "../../packages/swift").  Joining it onto `output_base` and
108        // normalising collapses the traversals to the actual project-root-
109        // relative path (e.g. "packages/swift").
110        let tests_base = normalize_path(&output_base.join(&pkg_path));
111
112        let field_resolver = FieldResolver::new(
113            &e2e_config.fields,
114            &e2e_config.fields_optional,
115            &e2e_config.result_fields,
116            &e2e_config.fields_array,
117            &HashSet::new(),
118        );
119
120        // One test file per fixture group.
121        for group in groups {
122            let active: Vec<&Fixture> = group
123                .fixtures
124                .iter()
125                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
126                .collect();
127
128            if active.is_empty() {
129                continue;
130            }
131
132            let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
133            let filename = format!("{class_name}.swift");
134            let content = render_test_file(
135                &group.category,
136                &active,
137                e2e_config,
138                module_name,
139                &class_name,
140                &function_name,
141                result_var,
142                &e2e_config.call.args,
143                &field_resolver,
144                result_is_simple,
145                &e2e_config.fields_enum,
146            );
147            files.push(GeneratedFile {
148                path: tests_base
149                    .join("Tests")
150                    .join(format!("{module_name}Tests"))
151                    .join(filename),
152                content,
153                generated_header: true,
154            });
155        }
156
157        Ok(files)
158    }
159
160    fn language_name(&self) -> &'static str {
161        "swift"
162    }
163}
164
165// ---------------------------------------------------------------------------
166// Rendering
167// ---------------------------------------------------------------------------
168
169fn render_package_swift(
170    module_name: &str,
171    registry_url: &str,
172    pkg_path: &str,
173    pkg_version: &str,
174    dep_mode: crate::config::DependencyMode,
175) -> String {
176    let min_macos = toolchain::SWIFT_MIN_MACOS;
177
178    // For local deps SwiftPM identity = last path component (e.g. "../../packages/swift" → "swift").
179    // For registry deps identity is inferred from the URL.
180    // Use explicit .product(name:package:) to avoid ambiguity under tools-version 6.0.
181    let (dep_block, product_dep) = match dep_mode {
182        crate::config::DependencyMode::Registry => {
183            let dep = format!(r#"        .package(url: "{registry_url}", from: "{pkg_version}")"#);
184            let pkg_id = registry_url
185                .trim_end_matches('/')
186                .trim_end_matches(".git")
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        crate::config::DependencyMode::Local => {
194            let dep = format!(r#"        .package(path: "{pkg_path}")"#);
195            let pkg_id = pkg_path
196                .trim_end_matches('/')
197                .split('/')
198                .next_back()
199                .unwrap_or(module_name);
200            let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
201            (dep, prod)
202        }
203    };
204    // SwiftPM platform enums use the major version only (.v13, .v14, ...);
205    // strip patch components to match the scaffold's `Package.swift`.
206    let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
207    format!(
208        r#"// swift-tools-version: 6.0
209import PackageDescription
210
211let package = Package(
212    name: "E2eSwift",
213    platforms: [
214        .macOS(.v{min_macos_major}),
215    ],
216    dependencies: [
217{dep_block},
218    ],
219    targets: [
220        .testTarget(
221            name: "{module_name}Tests",
222            dependencies: [{product_dep}]
223        ),
224    ]
225)
226"#
227    )
228}
229
230#[allow(clippy::too_many_arguments)]
231fn render_test_file(
232    category: &str,
233    fixtures: &[&Fixture],
234    e2e_config: &E2eConfig,
235    module_name: &str,
236    class_name: &str,
237    function_name: &str,
238    result_var: &str,
239    args: &[crate::config::ArgMapping],
240    field_resolver: &FieldResolver,
241    result_is_simple: bool,
242    enum_fields: &HashSet<String>,
243) -> String {
244    let mut out = String::new();
245    out.push_str(&hash::header(CommentStyle::DoubleSlash));
246    let _ = writeln!(out, "import XCTest");
247    let _ = writeln!(out, "import {module_name}");
248    let _ = writeln!(out, "import RustBridge");
249    let _ = writeln!(out);
250    let _ = writeln!(out, "/// E2e tests for category: {category}.");
251    let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
252
253    for fixture in fixtures {
254        if fixture.is_http_test() {
255            render_http_test_method(&mut out, fixture);
256        } else {
257            render_test_method(
258                &mut out,
259                fixture,
260                e2e_config,
261                function_name,
262                result_var,
263                args,
264                field_resolver,
265                result_is_simple,
266                enum_fields,
267            );
268        }
269        let _ = writeln!(out);
270    }
271
272    let _ = writeln!(out, "}}");
273    out
274}
275
276// ---------------------------------------------------------------------------
277// HTTP test rendering — TestClientRenderer impl + thin driver wrapper
278// ---------------------------------------------------------------------------
279
280/// Renderer that emits XCTest `func test...() throws` methods using `URLSession`
281/// against the mock server (`ProcessInfo.processInfo.environment["MOCK_SERVER_URL"]`).
282struct SwiftTestClientRenderer;
283
284impl client::TestClientRenderer for SwiftTestClientRenderer {
285    fn language_name(&self) -> &'static str {
286        "swift"
287    }
288
289    fn sanitize_test_name(&self, id: &str) -> String {
290        // Swift test methods are `func testFoo()` — upper-camel-case after "test".
291        sanitize_ident(id).to_upper_camel_case()
292    }
293
294    /// Emit `func test{FnName}() throws {` (or a skip stub when the fixture is skipped).
295    ///
296    /// XCTest has no first-class skip annotation prior to Swift Testing (`@Test`).
297    /// For skipped fixtures we emit `try XCTSkipIf(true, reason)` inside the
298    /// function body so XCTest records them as skipped rather than omitting them.
299    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
300        let _ = writeln!(out, "    /// {description}");
301        let _ = writeln!(out, "    func test{fn_name}() throws {{");
302        if let Some(reason) = skip_reason {
303            let escaped = escape_swift(reason);
304            let _ = writeln!(out, "        try XCTSkipIf(true, \"{escaped}\")");
305        }
306    }
307
308    fn render_test_close(&self, out: &mut String) {
309        let _ = writeln!(out, "    }}");
310    }
311
312    /// Emit a synchronous `URLSession` round-trip to the mock server.
313    ///
314    /// `ProcessInfo.processInfo.environment["MOCK_SERVER_URL"]!` provides the base
315    /// URL; the fixture path is appended directly.  The call uses a semaphore so the
316    /// generated test body stays synchronous (compatible with `throws` functions —
317    /// no `async` XCTest support needed).
318    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
319        let method = ctx.method.to_uppercase();
320        let fixture_path = escape_swift(ctx.path);
321
322        let _ = writeln!(
323            out,
324            "        let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
325        );
326        let _ = writeln!(
327            out,
328            "        var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
329        );
330        let _ = writeln!(out, "        _req.httpMethod = \"{method}\"");
331
332        // Headers
333        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
334        header_pairs.sort_by_key(|(k, _)| k.as_str());
335        for (k, v) in &header_pairs {
336            let expanded_v = expand_fixture_templates(v);
337            let ek = escape_swift(k);
338            let ev = escape_swift(&expanded_v);
339            let _ = writeln!(out, "        _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
340        }
341
342        // Body
343        if let Some(body) = ctx.body {
344            let json_str = serde_json::to_string(body).unwrap_or_default();
345            let escaped_body = escape_swift(&json_str);
346            let _ = writeln!(out, "        _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
347            let _ = writeln!(
348                out,
349                "        _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
350            );
351        }
352
353        let _ = writeln!(out, "        var {}: HTTPURLResponse?", ctx.response_var);
354        let _ = writeln!(out, "        var _responseData: Data?");
355        let _ = writeln!(out, "        let _sema = DispatchSemaphore(value: 0)");
356        let _ = writeln!(
357            out,
358            "        URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
359        );
360        let _ = writeln!(out, "            {} = resp as? HTTPURLResponse", ctx.response_var);
361        let _ = writeln!(out, "            _responseData = data");
362        let _ = writeln!(out, "            _sema.signal()");
363        let _ = writeln!(out, "        }}.resume()");
364        let _ = writeln!(out, "        _sema.wait()");
365        let _ = writeln!(out, "        let _resp = try XCTUnwrap({})", ctx.response_var);
366    }
367
368    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
369        let _ = writeln!(out, "        XCTAssertEqual(_resp.statusCode, {status})");
370    }
371
372    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
373        let lower_name = name.to_lowercase();
374        let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
375        match expected {
376            "<<present>>" => {
377                let _ = writeln!(out, "        XCTAssertNotNil({header_expr})");
378            }
379            "<<absent>>" => {
380                let _ = writeln!(out, "        XCTAssertNil({header_expr})");
381            }
382            "<<uuid>>" => {
383                let _ = writeln!(out, "        let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
384                let _ = writeln!(
385                    out,
386                    "        XCTAssertNotNil(_hdrVal_{lower_name}.range(of: #\"^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$\"#, options: .regularExpression))"
387                );
388            }
389            exact => {
390                let escaped = escape_swift(exact);
391                let _ = writeln!(out, "        XCTAssertEqual({header_expr}, \"{escaped}\")");
392            }
393        }
394    }
395
396    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
397        if let serde_json::Value::String(s) = expected {
398            let escaped = escape_swift(s);
399            let _ = writeln!(
400                out,
401                "        let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
402            );
403            let _ = writeln!(
404                out,
405                "        XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
406            );
407        } else {
408            let json_str = serde_json::to_string(expected).unwrap_or_default();
409            let escaped = escape_swift(&json_str);
410            let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
411            let _ = writeln!(
412                out,
413                "        let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
414            );
415            let _ = writeln!(
416                out,
417                "        let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
418            );
419            let _ = writeln!(
420                out,
421                "        XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
422            );
423        }
424    }
425
426    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
427        if let Some(obj) = expected.as_object() {
428            let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
429            let _ = writeln!(
430                out,
431                "        let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
432            );
433            for (key, val) in obj {
434                let escaped_key = escape_swift(key);
435                let swift_val = json_to_swift(val);
436                let _ = writeln!(
437                    out,
438                    "        XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
439                );
440            }
441        }
442    }
443
444    fn render_assert_validation_errors(
445        &self,
446        out: &mut String,
447        _response_var: &str,
448        errors: &[ValidationErrorExpectation],
449    ) {
450        let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
451        let _ = writeln!(
452            out,
453            "        let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
454        );
455        let _ = writeln!(
456            out,
457            "        let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
458        );
459        for ve in errors {
460            let escaped_msg = escape_swift(&ve.msg);
461            let _ = writeln!(
462                out,
463                "        XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
464            );
465        }
466    }
467}
468
469/// Render an XCTest method for an HTTP server fixture via the shared driver.
470///
471/// HTTP 101 (WebSocket upgrade) is emitted as a skip stub because `URLSession`
472/// cannot handle Upgrade responses.
473fn render_http_test_method(out: &mut String, fixture: &Fixture) {
474    let Some(http) = &fixture.http else {
475        return;
476    };
477
478    // HTTP 101 (WebSocket upgrade) — URLSession cannot handle upgrade responses.
479    if http.expected_response.status_code == 101 {
480        let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
481        let description = fixture.description.replace('"', "\\\"");
482        let _ = writeln!(out, "    /// {description}");
483        let _ = writeln!(out, "    func test{method_name}() throws {{");
484        let _ = writeln!(
485            out,
486            "        try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
487        );
488        let _ = writeln!(out, "    }}");
489        return;
490    }
491
492    client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
493}
494
495// ---------------------------------------------------------------------------
496// Function-call test rendering
497// ---------------------------------------------------------------------------
498
499#[allow(clippy::too_many_arguments)]
500fn render_test_method(
501    out: &mut String,
502    fixture: &Fixture,
503    e2e_config: &E2eConfig,
504    _function_name: &str,
505    _result_var: &str,
506    _args: &[crate::config::ArgMapping],
507    field_resolver: &FieldResolver,
508    result_is_simple: bool,
509    enum_fields: &HashSet<String>,
510) {
511    // Resolve per-fixture call config.
512    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
513    let lang = "swift";
514    let call_overrides = call_config.overrides.get(lang);
515    let function_name = call_overrides
516        .and_then(|o| o.function.as_ref())
517        .cloned()
518        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
519    let result_var = &call_config.result_var;
520    let args = &call_config.args;
521
522    let method_name = fixture.id.to_upper_camel_case();
523    let description = &fixture.description;
524    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
525    let is_async = call_config.r#async;
526
527    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
528
529    // Qualify function name with RustBridge module (Phase 2D: wrappers disabled).
530    let qualified_function_name = format!("RustBridge.{function_name}");
531
532    if is_async {
533        let _ = writeln!(out, "    func test{method_name}() async throws {{");
534    } else {
535        let _ = writeln!(out, "    func test{method_name}() throws {{");
536    }
537    let _ = writeln!(out, "        // {description}");
538
539    for line in &setup_lines {
540        let _ = writeln!(out, "        {line}");
541    }
542
543    if expects_error {
544        if is_async {
545            // XCTAssertThrowsError is a synchronous macro; for async-throwing
546            // functions use a do/catch with explicit XCTFail to enforce that
547            // the throw actually happens. `await XCTAssertThrowsError(...)` is
548            // not valid Swift — it evaluates `await` against a non-async expr.
549            let _ = writeln!(out, "        do {{");
550            let _ = writeln!(out, "            _ = try await {qualified_function_name}({args_str})");
551            let _ = writeln!(out, "            XCTFail(\"expected to throw\")");
552            let _ = writeln!(out, "        }} catch {{");
553            let _ = writeln!(out, "            // success");
554            let _ = writeln!(out, "        }}");
555        } else {
556            let _ = writeln!(
557                out,
558                "        XCTAssertThrowsError(try {qualified_function_name}({args_str}))"
559            );
560        }
561        let _ = writeln!(out, "    }}");
562        return;
563    }
564
565    if is_async {
566        let _ = writeln!(
567            out,
568            "        let {result_var} = try await {qualified_function_name}({args_str})"
569        );
570    } else {
571        let _ = writeln!(
572            out,
573            "        let {result_var} = try {qualified_function_name}({args_str})"
574        );
575    }
576
577    for assertion in &fixture.assertions {
578        render_assertion(
579            out,
580            assertion,
581            result_var,
582            field_resolver,
583            result_is_simple,
584            enum_fields,
585        );
586    }
587
588    let _ = writeln!(out, "    }}");
589}
590
591/// Build setup lines and the argument list for the function call.
592fn build_args_and_setup(
593    input: &serde_json::Value,
594    args: &[crate::config::ArgMapping],
595    fixture_id: &str,
596) -> (Vec<String>, String) {
597    if args.is_empty() {
598        return (Vec::new(), String::new());
599    }
600
601    let mut setup_lines: Vec<String> = Vec::new();
602    let mut parts: Vec<String> = Vec::new();
603
604    for arg in args {
605        if arg.arg_type == "mock_url" {
606            setup_lines.push(format!(
607                "let {} = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"",
608                arg.name,
609            ));
610            parts.push(arg.name.clone());
611            continue;
612        }
613
614        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
615        let val = input.get(field);
616        match val {
617            None | Some(serde_json::Value::Null) if arg.optional => {
618                continue;
619            }
620            None | Some(serde_json::Value::Null) => {
621                let default_val = match arg.arg_type.as_str() {
622                    "string" => "\"\"".to_string(),
623                    "int" | "integer" => "0".to_string(),
624                    "float" | "number" => "0.0".to_string(),
625                    "bool" | "boolean" => "false".to_string(),
626                    _ => "nil".to_string(),
627                };
628                parts.push(default_val);
629            }
630            Some(v) => {
631                parts.push(json_to_swift(v));
632            }
633        }
634    }
635
636    (setup_lines, parts.join(", "))
637}
638
639fn render_assertion(
640    out: &mut String,
641    assertion: &Assertion,
642    result_var: &str,
643    field_resolver: &FieldResolver,
644    result_is_simple: bool,
645    enum_fields: &HashSet<String>,
646) {
647    // Skip assertions on fields that don't exist on the result type.
648    if let Some(f) = &assertion.field {
649        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
650            let _ = writeln!(out, "        // skipped: field '{{f}}' not available on result type");
651            return;
652        }
653    }
654
655    // Determine if this field is an enum type.
656    let field_is_enum = assertion
657        .field
658        .as_deref()
659        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
660
661    let field_expr = if result_is_simple {
662        result_var.to_string()
663    } else {
664        match &assertion.field {
665            Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
666            _ => result_var.to_string(),
667        }
668    };
669
670    // For enum fields, use .rawValue to get the string value.
671    let string_expr = if field_is_enum {
672        format!("{field_expr}.rawValue")
673    } else {
674        field_expr.clone()
675    };
676
677    match assertion.assertion_type.as_str() {
678        "equals" => {
679            if let Some(expected) = &assertion.value {
680                let swift_val = json_to_swift(expected);
681                if expected.is_string() {
682                    // For optional strings (String?), use ?? to coalesce before trimming.
683                    let field_is_optional = assertion
684                        .field
685                        .as_deref()
686                        .is_some_and(|f| field_resolver.is_optional(f));
687                    let trim_expr = if field_is_optional {
688                        format!("({field_expr} ?? \"\").trimmingCharacters(in: .whitespaces)")
689                    } else {
690                        format!("{string_expr}.trimmingCharacters(in: .whitespaces)")
691                    };
692                    let _ = writeln!(out, "        XCTAssertEqual({trim_expr}, {swift_val})");
693                } else {
694                    let _ = writeln!(out, "        XCTAssertEqual({field_expr}, {swift_val})");
695                }
696            }
697        }
698        "contains" => {
699            if let Some(expected) = &assertion.value {
700                let swift_val = json_to_swift(expected);
701                let _ = writeln!(
702                    out,
703                    "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
704                );
705            }
706        }
707        "contains_all" => {
708            if let Some(values) = &assertion.values {
709                for val in values {
710                    let swift_val = json_to_swift(val);
711                    let _ = writeln!(
712                        out,
713                        "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
714                    );
715                }
716            }
717        }
718        "not_contains" => {
719            if let Some(expected) = &assertion.value {
720                let swift_val = json_to_swift(expected);
721                let _ = writeln!(
722                    out,
723                    "        XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
724                );
725            }
726        }
727        "not_empty" => {
728            let _ = writeln!(
729                out,
730                "        XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
731            );
732        }
733        "is_empty" => {
734            let _ = writeln!(
735                out,
736                "        XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
737            );
738        }
739        "contains_any" => {
740            if let Some(values) = &assertion.values {
741                let checks: Vec<String> = values
742                    .iter()
743                    .map(|v| {
744                        let swift_val = json_to_swift(v);
745                        format!("{string_expr}.contains({swift_val})")
746                    })
747                    .collect();
748                let joined = checks.join(" || ");
749                let _ = writeln!(
750                    out,
751                    "        XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
752                );
753            }
754        }
755        "greater_than" => {
756            if let Some(val) = &assertion.value {
757                let swift_val = json_to_swift(val);
758                let _ = writeln!(out, "        XCTAssertGreaterThan({field_expr}, {swift_val})");
759            }
760        }
761        "less_than" => {
762            if let Some(val) = &assertion.value {
763                let swift_val = json_to_swift(val);
764                let _ = writeln!(out, "        XCTAssertLessThan({field_expr}, {swift_val})");
765            }
766        }
767        "greater_than_or_equal" => {
768            if let Some(val) = &assertion.value {
769                let swift_val = json_to_swift(val);
770                let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({field_expr}, {swift_val})");
771            }
772        }
773        "less_than_or_equal" => {
774            if let Some(val) = &assertion.value {
775                let swift_val = json_to_swift(val);
776                let _ = writeln!(out, "        XCTAssertLessThanOrEqual({field_expr}, {swift_val})");
777            }
778        }
779        "starts_with" => {
780            if let Some(expected) = &assertion.value {
781                let swift_val = json_to_swift(expected);
782                let _ = writeln!(
783                    out,
784                    "        XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
785                );
786            }
787        }
788        "ends_with" => {
789            if let Some(expected) = &assertion.value {
790                let swift_val = json_to_swift(expected);
791                let _ = writeln!(
792                    out,
793                    "        XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
794                );
795            }
796        }
797        "min_length" => {
798            if let Some(val) = &assertion.value {
799                if let Some(n) = val.as_u64() {
800                    let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({field_expr}.count, {n})");
801                }
802            }
803        }
804        "max_length" => {
805            if let Some(val) = &assertion.value {
806                if let Some(n) = val.as_u64() {
807                    let _ = writeln!(out, "        XCTAssertLessThanOrEqual({field_expr}.count, {n})");
808                }
809            }
810        }
811        "count_min" => {
812            if let Some(val) = &assertion.value {
813                if let Some(n) = val.as_u64() {
814                    let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({field_expr}.count, {n})");
815                }
816            }
817        }
818        "count_equals" => {
819            if let Some(val) = &assertion.value {
820                if let Some(n) = val.as_u64() {
821                    let _ = writeln!(out, "        XCTAssertEqual({field_expr}.count, {n})");
822                }
823            }
824        }
825        "is_true" => {
826            let _ = writeln!(out, "        XCTAssertTrue({field_expr})");
827        }
828        "is_false" => {
829            let _ = writeln!(out, "        XCTAssertFalse({field_expr})");
830        }
831        "matches_regex" => {
832            if let Some(expected) = &assertion.value {
833                let swift_val = json_to_swift(expected);
834                let _ = writeln!(
835                    out,
836                    "        XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
837                );
838            }
839        }
840        "not_error" => {
841            // Already handled by the call succeeding without exception.
842        }
843        "error" => {
844            // Handled at the test method level.
845        }
846        "method_result" => {
847            let _ = writeln!(out, "        // method_result assertions not yet implemented for Swift");
848        }
849        other => {
850            panic!("Swift e2e generator: unsupported assertion type: {other}");
851        }
852    }
853}
854
855/// Normalise a path by resolving `..` components without hitting the filesystem.
856///
857/// This mirrors what `std::fs::canonicalize` does but works on paths that do
858/// not yet exist on disk (generated-file paths).  Only `..` traversals are
859/// collapsed; `.` components are dropped; nothing else is changed.
860fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
861    let mut components = std::path::PathBuf::new();
862    for component in path.components() {
863        match component {
864            std::path::Component::ParentDir => {
865                // Pop the last pushed component if there is one that isn't
866                // already a `..` (avoids over-collapsing `../../foo`).
867                if !components.as_os_str().is_empty() {
868                    components.pop();
869                } else {
870                    components.push(component);
871                }
872            }
873            std::path::Component::CurDir => {}
874            other => components.push(other),
875        }
876    }
877    components
878}
879
880/// Convert a `serde_json::Value` to a Swift literal string.
881fn json_to_swift(value: &serde_json::Value) -> String {
882    match value {
883        serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
884        serde_json::Value::Bool(b) => b.to_string(),
885        serde_json::Value::Number(n) => n.to_string(),
886        serde_json::Value::Null => "nil".to_string(),
887        serde_json::Value::Array(arr) => {
888            let items: Vec<String> = arr.iter().map(json_to_swift).collect();
889            format!("[{}]", items.join(", "))
890        }
891        serde_json::Value::Object(_) => {
892            let json_str = serde_json::to_string(value).unwrap_or_default();
893            format!("\"{}\"", escape_swift(&json_str))
894        }
895    }
896}
897
898/// Escape a string for embedding in a Swift double-quoted string literal.
899fn escape_swift(s: &str) -> String {
900    escape_swift_str(s)
901}