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