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