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