Skip to main content

alef_e2e/codegen/
swift.rs

1//! Swift e2e test generator using XCTest.
2//!
3//! Generates test files for the swift package in `packages/swift/Tests/<Module>Tests/`.
4//!
5//! IMPORTANT: Due to SwiftPM 6.0 limitations (forbids inter-package `.package(path:)`
6//! references within a monorepo), generated test files are placed directly inside
7//! the `packages/swift` package (not in a separate `e2e/swift` package). This allows
8//! tests to depend on the library target without an explicit package dependency.
9//!
10//! The generated `Package.swift` is placed in `e2e/swift/` for documentation and CI
11//! reference but is NOT used for running tests — tests are run from the
12//! `packages/swift/` directory using `swift test`.
13
14use crate::config::E2eConfig;
15use crate::escape::{escape_java as escape_swift_str, expand_fixture_templates, sanitize_filename, sanitize_ident};
16use crate::field_access::FieldResolver;
17use crate::fixture::{Assertion, Fixture, FixtureGroup, ValidationErrorExpectation};
18use alef_core::backend::GeneratedFile;
19use alef_core::config::ResolvedCrateConfig;
20use alef_core::hash::{self, CommentStyle};
21use alef_core::template_versions::toolchain;
22use anyhow::Result;
23use heck::{ToLowerCamelCase, ToUpperCamelCase};
24use std::collections::HashSet;
25use std::fmt::Write as FmtWrite;
26use std::path::PathBuf;
27
28use super::E2eCodegen;
29use super::client;
30
31/// Swift e2e code generator.
32pub struct SwiftE2eCodegen;
33
34impl E2eCodegen for SwiftE2eCodegen {
35    fn generate(
36        &self,
37        groups: &[FixtureGroup],
38        e2e_config: &E2eConfig,
39        config: &ResolvedCrateConfig,
40    ) -> Result<Vec<GeneratedFile>> {
41        let lang = self.language_name();
42        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
43
44        let mut files = Vec::new();
45
46        // Resolve call config with overrides.
47        let call = &e2e_config.call;
48        let overrides = call.overrides.get(lang);
49        let function_name = overrides
50            .and_then(|o| o.function.as_ref())
51            .cloned()
52            .unwrap_or_else(|| call.function.clone());
53        let result_var = &call.result_var;
54        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
55
56        // Resolve package config.
57        let swift_pkg = e2e_config.resolve_package("swift");
58        let pkg_name = swift_pkg
59            .as_ref()
60            .and_then(|p| p.name.as_ref())
61            .cloned()
62            .unwrap_or_else(|| config.name.to_upper_camel_case());
63        let pkg_path = swift_pkg
64            .as_ref()
65            .and_then(|p| p.path.as_ref())
66            .cloned()
67            .unwrap_or_else(|| "../../packages/swift".to_string());
68        let pkg_version = swift_pkg
69            .as_ref()
70            .and_then(|p| p.version.as_ref())
71            .cloned()
72            .or_else(|| config.resolved_version())
73            .unwrap_or_else(|| "0.1.0".to_string());
74
75        // The Swift module name: UpperCamelCase of the package name.
76        let module_name = pkg_name.as_str();
77
78        // Resolve the registry URL: derive from the configured repository when
79        // available (with a `.git` suffix per SwiftPM convention). Falls back
80        // to a vendor-neutral placeholder when no repo is configured.
81        let registry_url = config
82            .try_github_repo()
83            .map(|repo| {
84                let base = repo.trim_end_matches('/').trim_end_matches(".git");
85                format!("{base}.git")
86            })
87            .unwrap_or_else(|_| format!("https://example.invalid/{module_name}.git"));
88
89        // Generate Package.swift (kept for tooling/CI reference but not used
90        // for running tests — see note below).
91        files.push(GeneratedFile {
92            path: output_base.join("Package.swift"),
93            content: render_package_swift(module_name, &registry_url, &pkg_path, &pkg_version, e2e_config.dep_mode),
94            generated_header: false,
95        });
96
97        // Swift e2e tests are written into the *packages/swift* package rather
98        // than into the separate e2e/swift package.  SwiftPM 6.0 forbids local
99        // `.package(path:)` references between packages inside the same git
100        // repository, so a standalone e2e/swift package cannot depend on
101        // packages/swift.  Placing the test files directly inside
102        // packages/swift/Tests/<Module>Tests/ sidesteps the restriction: the
103        // tests are part of the same SwiftPM package that defines the library
104        // target, so no inter-package dependency is needed.
105        //
106        // `pkg_path` is expressed relative to the e2e/<lang> directory (e.g.
107        // "../../packages/swift").  Joining it onto `output_base` and
108        // normalising collapses the traversals to the actual project-root-
109        // relative path (e.g. "packages/swift").
110        let tests_base = normalize_path(&output_base.join(&pkg_path));
111
112        let field_resolver = FieldResolver::new(
113            &e2e_config.fields,
114            &e2e_config.fields_optional,
115            &e2e_config.result_fields,
116            &e2e_config.fields_array,
117            &e2e_config.fields_method_calls,
118        );
119
120        // One test file per fixture group.
121        for group in groups {
122            let active: Vec<&Fixture> = group
123                .fixtures
124                .iter()
125                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
126                .collect();
127
128            if active.is_empty() {
129                continue;
130            }
131
132            let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
133            let filename = format!("{class_name}.swift");
134            let content = render_test_file(
135                &group.category,
136                &active,
137                e2e_config,
138                module_name,
139                &class_name,
140                &function_name,
141                result_var,
142                &e2e_config.call.args,
143                &field_resolver,
144                result_is_simple,
145                &e2e_config.fields_enum,
146            );
147            files.push(GeneratedFile {
148                path: tests_base
149                    .join("Tests")
150                    .join(format!("{module_name}Tests"))
151                    .join(filename),
152                content,
153                generated_header: true,
154            });
155        }
156
157        Ok(files)
158    }
159
160    fn language_name(&self) -> &'static str {
161        "swift"
162    }
163}
164
165// ---------------------------------------------------------------------------
166// Rendering
167// ---------------------------------------------------------------------------
168
169fn render_package_swift(
170    module_name: &str,
171    registry_url: &str,
172    pkg_path: &str,
173    pkg_version: &str,
174    dep_mode: crate::config::DependencyMode,
175) -> String {
176    let min_macos = toolchain::SWIFT_MIN_MACOS;
177
178    // For local deps SwiftPM identity = last path component (e.g. "../../packages/swift" → "swift").
179    // For registry deps identity is inferred from the URL.
180    // Use explicit .product(name:package:) to avoid ambiguity under tools-version 6.0.
181    let (dep_block, product_dep) = match dep_mode {
182        crate::config::DependencyMode::Registry => {
183            let dep = format!(r#"        .package(url: "{registry_url}", from: "{pkg_version}")"#);
184            let pkg_id = registry_url
185                .trim_end_matches('/')
186                .trim_end_matches(".git")
187                .split('/')
188                .next_back()
189                .unwrap_or(module_name);
190            let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
191            (dep, prod)
192        }
193        crate::config::DependencyMode::Local => {
194            let dep = format!(r#"        .package(path: "{pkg_path}")"#);
195            let pkg_id = pkg_path
196                .trim_end_matches('/')
197                .split('/')
198                .next_back()
199                .unwrap_or(module_name);
200            let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
201            (dep, prod)
202        }
203    };
204    // SwiftPM platform enums use the major version only (.v13, .v14, ...);
205    // strip patch components to match the scaffold's `Package.swift`.
206    let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
207    format!(
208        r#"// swift-tools-version: 6.0
209import PackageDescription
210
211let package = Package(
212    name: "E2eSwift",
213    platforms: [
214        .macOS(.v{min_macos_major}),
215    ],
216    dependencies: [
217{dep_block},
218    ],
219    targets: [
220        .testTarget(
221            name: "{module_name}Tests",
222            dependencies: [{product_dep}]
223        ),
224    ]
225)
226"#
227    )
228}
229
230#[allow(clippy::too_many_arguments)]
231fn render_test_file(
232    category: &str,
233    fixtures: &[&Fixture],
234    e2e_config: &E2eConfig,
235    module_name: &str,
236    class_name: &str,
237    function_name: &str,
238    result_var: &str,
239    args: &[crate::config::ArgMapping],
240    field_resolver: &FieldResolver,
241    result_is_simple: bool,
242    enum_fields: &HashSet<String>,
243) -> String {
244    // Detect whether any fixture in this group uses a file_path or bytes arg — if so
245    // the test class chdir's to <repo>/test_documents at setUp time so the
246    // fixture-relative paths in test bodies (e.g. "docx/fake.docx") resolve correctly.
247    // The Swift binding's `extractBytes`/`extractFile` e2e wrappers consult
248    // `FIXTURES_DIR` first, otherwise resolve against the current directory.
249    // Mirrors the Ruby/Python conftest pattern that chdirs to test_documents.
250    let needs_chdir = fixtures.iter().any(|f| {
251        let call_config = e2e_config.resolve_call(f.call.as_deref());
252        call_config
253            .args
254            .iter()
255            .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
256    });
257
258    let mut out = String::new();
259    out.push_str(&hash::header(CommentStyle::DoubleSlash));
260    let _ = writeln!(out, "import XCTest");
261    let _ = writeln!(out, "import Foundation");
262    let _ = writeln!(out, "import {module_name}");
263    let _ = writeln!(out, "import RustBridge");
264    let _ = writeln!(out);
265    let _ = writeln!(out, "/// E2e tests for category: {category}.");
266    let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
267
268    if needs_chdir {
269        // Chdir once at class setUp so all fixture file_path arguments resolve relative
270        // to the repository's test_documents directory.
271        //
272        // #filePath = <repo>/packages/swift/Tests/<Module>Tests/<Class>.swift
273        // 5 deletingLastPathComponent() calls climb to the repo root before appending
274        // "test_documents". Mirrors the Ruby/Python conftest pattern that chdirs to
275        // test_documents.
276        let _ = writeln!(out, "    override class func setUp() {{");
277        let _ = writeln!(out, "        super.setUp()");
278        let _ = writeln!(out, "        let _testDocs = URL(fileURLWithPath: #filePath)");
279        let _ = writeln!(out, "            .deletingLastPathComponent() // <Module>Tests/");
280        let _ = writeln!(out, "            .deletingLastPathComponent() // Tests/");
281        let _ = writeln!(out, "            .deletingLastPathComponent() // swift/");
282        let _ = writeln!(out, "            .deletingLastPathComponent() // packages/");
283        let _ = writeln!(out, "            .deletingLastPathComponent() // <repo root>");
284        let _ = writeln!(out, "            .appendingPathComponent(\"test_documents\")");
285        let _ = writeln!(
286            out,
287            "        if FileManager.default.fileExists(atPath: _testDocs.path) {{"
288        );
289        let _ = writeln!(
290            out,
291            "            FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
292        );
293        let _ = writeln!(out, "        }}");
294        let _ = writeln!(out, "    }}");
295        let _ = writeln!(out);
296    }
297
298    for fixture in fixtures {
299        if fixture.is_http_test() {
300            render_http_test_method(&mut out, fixture);
301        } else {
302            render_test_method(
303                &mut out,
304                fixture,
305                e2e_config,
306                function_name,
307                result_var,
308                args,
309                field_resolver,
310                result_is_simple,
311                enum_fields,
312            );
313        }
314        let _ = writeln!(out);
315    }
316
317    let _ = writeln!(out, "}}");
318    out
319}
320
321// ---------------------------------------------------------------------------
322// HTTP test rendering — TestClientRenderer impl + thin driver wrapper
323// ---------------------------------------------------------------------------
324
325/// Renderer that emits XCTest `func test...() throws` methods using `URLSession`
326/// against the mock server (`ProcessInfo.processInfo.environment["MOCK_SERVER_URL"]`).
327struct SwiftTestClientRenderer;
328
329impl client::TestClientRenderer for SwiftTestClientRenderer {
330    fn language_name(&self) -> &'static str {
331        "swift"
332    }
333
334    fn sanitize_test_name(&self, id: &str) -> String {
335        // Swift test methods are `func testFoo()` — upper-camel-case after "test".
336        sanitize_ident(id).to_upper_camel_case()
337    }
338
339    /// Emit `func test{FnName}() throws {` (or a skip stub when the fixture is skipped).
340    ///
341    /// XCTest has no first-class skip annotation prior to Swift Testing (`@Test`).
342    /// For skipped fixtures we emit `try XCTSkipIf(true, reason)` inside the
343    /// function body so XCTest records them as skipped rather than omitting them.
344    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
345        let _ = writeln!(out, "    /// {description}");
346        let _ = writeln!(out, "    func test{fn_name}() throws {{");
347        if let Some(reason) = skip_reason {
348            let escaped = escape_swift(reason);
349            let _ = writeln!(out, "        try XCTSkipIf(true, \"{escaped}\")");
350        }
351    }
352
353    fn render_test_close(&self, out: &mut String) {
354        let _ = writeln!(out, "    }}");
355    }
356
357    /// Emit a synchronous `URLSession` round-trip to the mock server.
358    ///
359    /// `ProcessInfo.processInfo.environment["MOCK_SERVER_URL"]!` provides the base
360    /// URL; the fixture path is appended directly.  The call uses a semaphore so the
361    /// generated test body stays synchronous (compatible with `throws` functions —
362    /// no `async` XCTest support needed).
363    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
364        let method = ctx.method.to_uppercase();
365        let fixture_path = escape_swift(ctx.path);
366
367        let _ = writeln!(
368            out,
369            "        let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
370        );
371        let _ = writeln!(
372            out,
373            "        var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
374        );
375        let _ = writeln!(out, "        _req.httpMethod = \"{method}\"");
376
377        // Headers
378        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
379        header_pairs.sort_by_key(|(k, _)| k.as_str());
380        for (k, v) in &header_pairs {
381            let expanded_v = expand_fixture_templates(v);
382            let ek = escape_swift(k);
383            let ev = escape_swift(&expanded_v);
384            let _ = writeln!(out, "        _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
385        }
386
387        // Body
388        if let Some(body) = ctx.body {
389            let json_str = serde_json::to_string(body).unwrap_or_default();
390            let escaped_body = escape_swift(&json_str);
391            let _ = writeln!(out, "        _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
392            let _ = writeln!(
393                out,
394                "        _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
395            );
396        }
397
398        let _ = writeln!(out, "        var {}: HTTPURLResponse?", ctx.response_var);
399        let _ = writeln!(out, "        var _responseData: Data?");
400        let _ = writeln!(out, "        let _sema = DispatchSemaphore(value: 0)");
401        let _ = writeln!(
402            out,
403            "        URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
404        );
405        let _ = writeln!(out, "            {} = resp as? HTTPURLResponse", ctx.response_var);
406        let _ = writeln!(out, "            _responseData = data");
407        let _ = writeln!(out, "            _sema.signal()");
408        let _ = writeln!(out, "        }}.resume()");
409        let _ = writeln!(out, "        _sema.wait()");
410        let _ = writeln!(out, "        let _resp = try XCTUnwrap({})", ctx.response_var);
411    }
412
413    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
414        let _ = writeln!(out, "        XCTAssertEqual(_resp.statusCode, {status})");
415    }
416
417    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
418        let lower_name = name.to_lowercase();
419        let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
420        match expected {
421            "<<present>>" => {
422                let _ = writeln!(out, "        XCTAssertNotNil({header_expr})");
423            }
424            "<<absent>>" => {
425                let _ = writeln!(out, "        XCTAssertNil({header_expr})");
426            }
427            "<<uuid>>" => {
428                let _ = writeln!(out, "        let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
429                let _ = writeln!(
430                    out,
431                    "        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))"
432                );
433            }
434            exact => {
435                let escaped = escape_swift(exact);
436                let _ = writeln!(out, "        XCTAssertEqual({header_expr}, \"{escaped}\")");
437            }
438        }
439    }
440
441    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
442        if let serde_json::Value::String(s) = expected {
443            let escaped = escape_swift(s);
444            let _ = writeln!(
445                out,
446                "        let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
447            );
448            let _ = writeln!(
449                out,
450                "        XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
451            );
452        } else {
453            let json_str = serde_json::to_string(expected).unwrap_or_default();
454            let escaped = escape_swift(&json_str);
455            let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
456            let _ = writeln!(
457                out,
458                "        let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
459            );
460            let _ = writeln!(
461                out,
462                "        let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
463            );
464            let _ = writeln!(
465                out,
466                "        XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
467            );
468        }
469    }
470
471    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
472        if let Some(obj) = expected.as_object() {
473            let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
474            let _ = writeln!(
475                out,
476                "        let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
477            );
478            for (key, val) in obj {
479                let escaped_key = escape_swift(key);
480                let swift_val = json_to_swift(val);
481                let _ = writeln!(
482                    out,
483                    "        XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
484                );
485            }
486        }
487    }
488
489    fn render_assert_validation_errors(
490        &self,
491        out: &mut String,
492        _response_var: &str,
493        errors: &[ValidationErrorExpectation],
494    ) {
495        let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
496        let _ = writeln!(
497            out,
498            "        let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
499        );
500        let _ = writeln!(
501            out,
502            "        let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
503        );
504        for ve in errors {
505            let escaped_msg = escape_swift(&ve.msg);
506            let _ = writeln!(
507                out,
508                "        XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
509            );
510        }
511    }
512}
513
514/// Render an XCTest method for an HTTP server fixture via the shared driver.
515///
516/// HTTP 101 (WebSocket upgrade) is emitted as a skip stub because `URLSession`
517/// cannot handle Upgrade responses.
518fn render_http_test_method(out: &mut String, fixture: &Fixture) {
519    let Some(http) = &fixture.http else {
520        return;
521    };
522
523    // HTTP 101 (WebSocket upgrade) — URLSession cannot handle upgrade responses.
524    if http.expected_response.status_code == 101 {
525        let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
526        let description = fixture.description.replace('"', "\\\"");
527        let _ = writeln!(out, "    /// {description}");
528        let _ = writeln!(out, "    func test{method_name}() throws {{");
529        let _ = writeln!(
530            out,
531            "        try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
532        );
533        let _ = writeln!(out, "    }}");
534        return;
535    }
536
537    client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
538}
539
540// ---------------------------------------------------------------------------
541// Function-call test rendering
542// ---------------------------------------------------------------------------
543
544#[allow(clippy::too_many_arguments)]
545fn render_test_method(
546    out: &mut String,
547    fixture: &Fixture,
548    e2e_config: &E2eConfig,
549    _function_name: &str,
550    _result_var: &str,
551    _args: &[crate::config::ArgMapping],
552    field_resolver: &FieldResolver,
553    result_is_simple: bool,
554    enum_fields: &HashSet<String>,
555) {
556    // Resolve per-fixture call config.
557    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
558    let lang = "swift";
559    let call_overrides = call_config.overrides.get(lang);
560    let function_name = call_overrides
561        .and_then(|o| o.function.as_ref())
562        .cloned()
563        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
564    let result_var = &call_config.result_var;
565    let args = &call_config.args;
566    // Per-call flags override the global default.
567    let result_is_simple = call_config.result_is_simple || result_is_simple;
568    let result_is_array = call_config.result_is_array;
569
570    let method_name = fixture.id.to_upper_camel_case();
571    let description = &fixture.description;
572    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
573    let is_async = call_config.r#async;
574
575    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id, &function_name);
576
577    // Use unqualified function name — the Kreuzberg module (imported by the test)
578    // provides convenience overloads that accept plain Swift types (String,
579    // [String], JSON strings) and delegate to the RustBridge layer internally.
580    let qualified_function_name = function_name.clone();
581
582    if is_async {
583        let _ = writeln!(out, "    func test{method_name}() async throws {{");
584    } else {
585        let _ = writeln!(out, "    func test{method_name}() throws {{");
586    }
587    let _ = writeln!(out, "        // {description}");
588
589    for line in &setup_lines {
590        let _ = writeln!(out, "        {line}");
591    }
592
593    if expects_error {
594        if is_async {
595            // XCTAssertThrowsError is a synchronous macro; for async-throwing
596            // functions use a do/catch with explicit XCTFail to enforce that
597            // the throw actually happens. `await XCTAssertThrowsError(...)` is
598            // not valid Swift — it evaluates `await` against a non-async expr.
599            let _ = writeln!(out, "        do {{");
600            let _ = writeln!(out, "            _ = try await {qualified_function_name}({args_str})");
601            let _ = writeln!(out, "            XCTFail(\"expected to throw\")");
602            let _ = writeln!(out, "        }} catch {{");
603            let _ = writeln!(out, "            // success");
604            let _ = writeln!(out, "        }}");
605        } else {
606            let _ = writeln!(
607                out,
608                "        XCTAssertThrowsError(try {qualified_function_name}({args_str}))"
609            );
610        }
611        let _ = writeln!(out, "    }}");
612        return;
613    }
614
615    if is_async {
616        let _ = writeln!(
617            out,
618            "        let {result_var} = try await {qualified_function_name}({args_str})"
619        );
620    } else {
621        let _ = writeln!(
622            out,
623            "        let {result_var} = try {qualified_function_name}({args_str})"
624        );
625    }
626
627    for assertion in &fixture.assertions {
628        render_assertion(
629            out,
630            assertion,
631            result_var,
632            field_resolver,
633            result_is_simple,
634            result_is_array,
635            enum_fields,
636        );
637    }
638
639    let _ = writeln!(out, "    }}");
640}
641
642/// Build setup lines and the argument list for the function call.
643fn build_args_and_setup(
644    input: &serde_json::Value,
645    args: &[crate::config::ArgMapping],
646    fixture_id: &str,
647    function_name: &str,
648) -> (Vec<String>, String) {
649    if args.is_empty() {
650        return (Vec::new(), String::new());
651    }
652
653    let mut setup_lines: Vec<String> = Vec::new();
654    let mut parts: Vec<String> = Vec::new();
655
656    for arg in args {
657        if arg.arg_type == "mock_url" {
658            setup_lines.push(format!(
659                "let {} = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"",
660                arg.name,
661            ));
662            parts.push(arg.name.clone());
663            continue;
664        }
665
666        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
667        let val = input.get(field);
668        match val {
669            None | Some(serde_json::Value::Null) if arg.optional => {
670                // For json_object "config" args in non-batch extract functions, the swift
671                // e2e wrappers always require a configJson String parameter. Provide an
672                // empty JSON object so the call signature matches the wrapper.
673                // Batch functions (batchExtract*) hardcode config internally — skip it.
674                let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
675                let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
676                if is_config_arg && !is_batch_fn {
677                    parts.push("\"{}\"".to_string());
678                } else {
679                    continue;
680                }
681            }
682            None | Some(serde_json::Value::Null) => {
683                let default_val = match arg.arg_type.as_str() {
684                    "string" => "\"\"".to_string(),
685                    "int" | "integer" => "0".to_string(),
686                    "float" | "number" => "0.0".to_string(),
687                    "bool" | "boolean" => "false".to_string(),
688                    _ => "nil".to_string(),
689                };
690                parts.push(default_val);
691            }
692            Some(v) => {
693                parts.push(json_to_swift(v));
694            }
695        }
696    }
697
698    (setup_lines, parts.join(", "))
699}
700
701fn render_assertion(
702    out: &mut String,
703    assertion: &Assertion,
704    result_var: &str,
705    field_resolver: &FieldResolver,
706    result_is_simple: bool,
707    result_is_array: bool,
708    enum_fields: &HashSet<String>,
709) {
710    // Skip assertions on fields that don't exist on the result type.
711    if let Some(f) = &assertion.field {
712        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
713            let _ = writeln!(out, "        // skipped: field '{{f}}' not available on result type");
714            return;
715        }
716    }
717
718    // Skip assertions that traverse a tagged-union variant boundary.
719    // In Swift, FormatMetadata and similar enum-backed opaque types are exposed as
720    // plain classes by swift-bridge — variant accessor methods (e.g., `.excel()`)
721    // are not generated, so such assertions cannot be expressed.
722    if let Some(f) = &assertion.field {
723        if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
724            let _ = writeln!(
725                out,
726                "        // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
727            );
728            return;
729        }
730    }
731
732    // Determine if this field is an enum type.
733    let field_is_enum = assertion
734        .field
735        .as_deref()
736        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
737
738    let field_expr = if result_is_simple {
739        result_var.to_string()
740    } else {
741        match &assertion.field {
742            Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
743            _ => result_var.to_string(),
744        }
745    };
746
747    // For enum fields, use .rawValue to get the string value.
748    // For other fields: swift-bridge returns all Rust `String` fields as `RustString`.
749    // We add .toString() here so string assertions (contains, hasPrefix, etc.) work.
750    // Non-string opaque fields (DocumentStructure, etc.) should not appear in string
751    // assertions — the fixture schema controls which assertions apply to which fields.
752    let string_expr = if field_is_enum {
753        format!("{field_expr}.rawValue")
754    } else {
755        format!("{field_expr}.toString()")
756    };
757
758    match assertion.assertion_type.as_str() {
759        "equals" => {
760            if let Some(expected) = &assertion.value {
761                let swift_val = json_to_swift(expected);
762                if expected.is_string() {
763                    // For optional strings (String?), use ?? to coalesce before trimming.
764                    // `.toString()` converts RustString → Swift String before calling
765                    // `.trimmingCharacters`, which requires a concrete String type.
766                    let field_is_optional = assertion
767                        .field
768                        .as_deref()
769                        .is_some_and(|f| field_resolver.is_optional(f));
770                    let trim_expr = if field_is_optional {
771                        format!("(({field_expr})?.toString() ?? \"\").trimmingCharacters(in: .whitespaces)")
772                    } else {
773                        // string_expr already has .toString() appended; just trim.
774                        format!("{string_expr}.trimmingCharacters(in: .whitespaces)")
775                    };
776                    let _ = writeln!(out, "        XCTAssertEqual({trim_expr}, {swift_val})");
777                } else {
778                    let _ = writeln!(out, "        XCTAssertEqual({field_expr}, {swift_val})");
779                }
780            }
781        }
782        "contains" => {
783            if let Some(expected) = &assertion.value {
784                let swift_val = json_to_swift(expected);
785                // When the root result IS the array (result_is_simple + result_is_array) and
786                // there is no field path, check array membership via map+contains.
787                let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
788                if result_is_simple && result_is_array && no_field {
789                    // RustVec<RustString> iteration yields RustStringRef (no `toString()`);
790                    // use `.as_str().toString()` to convert each element to a Swift String.
791                    let _ = writeln!(
792                        out,
793                        "        XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
794                    );
795                } else {
796                    // For array fields (RustVec<RustString>), check membership via map+contains.
797                    let field_is_array = assertion
798                        .field
799                        .as_deref()
800                        .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
801                    if field_is_array {
802                        let contains_expr =
803                            swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
804                        let _ = writeln!(
805                            out,
806                            "        XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
807                        );
808                    } else {
809                        let _ = writeln!(
810                            out,
811                            "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
812                        );
813                    }
814                }
815            }
816        }
817        "contains_all" => {
818            if let Some(values) = &assertion.values {
819                // For array fields (RustVec<RustString>), check membership via map+contains.
820                let field_is_array = assertion
821                    .field
822                    .as_deref()
823                    .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
824                if field_is_array {
825                    let contains_expr =
826                        swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
827                    for val in values {
828                        let swift_val = json_to_swift(val);
829                        let _ = writeln!(
830                            out,
831                            "        XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
832                        );
833                    }
834                } else {
835                    for val in values {
836                        let swift_val = json_to_swift(val);
837                        let _ = writeln!(
838                            out,
839                            "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
840                        );
841                    }
842                }
843            }
844        }
845        "not_contains" => {
846            if let Some(expected) = &assertion.value {
847                let swift_val = json_to_swift(expected);
848                let _ = writeln!(
849                    out,
850                    "        XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
851                );
852            }
853        }
854        "not_empty" => {
855            // For optional fields (Optional<T>), check that the value is non-nil.
856            // For string fields, convert to Swift String and check .isEmpty.
857            let field_is_optional = assertion
858                .field
859                .as_deref()
860                .is_some_and(|f| field_resolver.is_optional(f));
861            if field_is_optional {
862                let _ = writeln!(out, "        XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
863            } else {
864                // string_expr has .toString() appended; .isEmpty works on Swift String.
865                let _ = writeln!(
866                    out,
867                    "        XCTAssertFalse({string_expr}.isEmpty, \"expected non-empty value\")"
868                );
869            }
870        }
871        "is_empty" => {
872            let field_is_optional = assertion
873                .field
874                .as_deref()
875                .is_some_and(|f| field_resolver.is_optional(f));
876            if field_is_optional {
877                let _ = writeln!(out, "        XCTAssertNil({field_expr}, \"expected nil value\")");
878            } else {
879                let _ = writeln!(
880                    out,
881                    "        XCTAssertTrue({string_expr}.isEmpty, \"expected empty value\")"
882                );
883            }
884        }
885        "contains_any" => {
886            if let Some(values) = &assertion.values {
887                let checks: Vec<String> = values
888                    .iter()
889                    .map(|v| {
890                        let swift_val = json_to_swift(v);
891                        format!("{string_expr}.contains({swift_val})")
892                    })
893                    .collect();
894                let joined = checks.join(" || ");
895                let _ = writeln!(
896                    out,
897                    "        XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
898                );
899            }
900        }
901        "greater_than" => {
902            if let Some(val) = &assertion.value {
903                let swift_val = json_to_swift(val);
904                // For optional numeric fields, coalesce to 0 before comparing.
905                let field_is_optional = assertion
906                    .field
907                    .as_deref()
908                    .is_some_and(|f| field_resolver.is_optional(f));
909                let compare_expr = if field_is_optional {
910                    format!("({field_expr} ?? 0)")
911                } else {
912                    field_expr.clone()
913                };
914                let _ = writeln!(out, "        XCTAssertGreaterThan({compare_expr}, {swift_val})");
915            }
916        }
917        "less_than" => {
918            if let Some(val) = &assertion.value {
919                let swift_val = json_to_swift(val);
920                let field_is_optional = assertion
921                    .field
922                    .as_deref()
923                    .is_some_and(|f| field_resolver.is_optional(f));
924                let compare_expr = if field_is_optional {
925                    format!("({field_expr} ?? 0)")
926                } else {
927                    field_expr.clone()
928                };
929                let _ = writeln!(out, "        XCTAssertLessThan({compare_expr}, {swift_val})");
930            }
931        }
932        "greater_than_or_equal" => {
933            if let Some(val) = &assertion.value {
934                let swift_val = json_to_swift(val);
935                // For optional numeric fields, coalesce to 0 before comparing.
936                let field_is_optional = assertion
937                    .field
938                    .as_deref()
939                    .is_some_and(|f| field_resolver.is_optional(f));
940                let compare_expr = if field_is_optional {
941                    format!("({field_expr} ?? 0)")
942                } else {
943                    field_expr.clone()
944                };
945                let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
946            }
947        }
948        "less_than_or_equal" => {
949            if let Some(val) = &assertion.value {
950                let swift_val = json_to_swift(val);
951                let field_is_optional = assertion
952                    .field
953                    .as_deref()
954                    .is_some_and(|f| field_resolver.is_optional(f));
955                let compare_expr = if field_is_optional {
956                    format!("({field_expr} ?? 0)")
957                } else {
958                    field_expr.clone()
959                };
960                let _ = writeln!(out, "        XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
961            }
962        }
963        "starts_with" => {
964            if let Some(expected) = &assertion.value {
965                let swift_val = json_to_swift(expected);
966                let _ = writeln!(
967                    out,
968                    "        XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
969                );
970            }
971        }
972        "ends_with" => {
973            if let Some(expected) = &assertion.value {
974                let swift_val = json_to_swift(expected);
975                let _ = writeln!(
976                    out,
977                    "        XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
978                );
979            }
980        }
981        "min_length" => {
982            if let Some(val) = &assertion.value {
983                if let Some(n) = val.as_u64() {
984                    // Use string_expr.count: for RustString fields string_expr already has
985                    // .toString() appended, giving a Swift String whose .count is character count.
986                    let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
987                }
988            }
989        }
990        "max_length" => {
991            if let Some(val) = &assertion.value {
992                if let Some(n) = val.as_u64() {
993                    let _ = writeln!(out, "        XCTAssertLessThanOrEqual({string_expr}.count, {n})");
994                }
995            }
996        }
997        "count_min" => {
998            if let Some(val) = &assertion.value {
999                if let Some(n) = val.as_u64() {
1000                    // For fields nested inside an optional parent (e.g. document.nodes where
1001                    // document is Optional), the accessor generates `result.document().nodes()`
1002                    // which doesn't compile in Swift without optional chaining.
1003                    let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1004                    let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1005                }
1006            }
1007        }
1008        "count_equals" => {
1009            if let Some(val) = &assertion.value {
1010                if let Some(n) = val.as_u64() {
1011                    let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1012                    let _ = writeln!(out, "        XCTAssertEqual({count_expr}, {n})");
1013                }
1014            }
1015        }
1016        "is_true" => {
1017            let _ = writeln!(out, "        XCTAssertTrue({field_expr})");
1018        }
1019        "is_false" => {
1020            let _ = writeln!(out, "        XCTAssertFalse({field_expr})");
1021        }
1022        "matches_regex" => {
1023            if let Some(expected) = &assertion.value {
1024                let swift_val = json_to_swift(expected);
1025                let _ = writeln!(
1026                    out,
1027                    "        XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1028                );
1029            }
1030        }
1031        "not_error" => {
1032            // Already handled by the call succeeding without exception.
1033        }
1034        "error" => {
1035            // Handled at the test method level.
1036        }
1037        "method_result" => {
1038            let _ = writeln!(out, "        // method_result assertions not yet implemented for Swift");
1039        }
1040        other => {
1041            panic!("Swift e2e generator: unsupported assertion type: {other}");
1042        }
1043    }
1044}
1045
1046/// Build a Swift accessor path for the given fixture field, inserting `()` on
1047/// every segment and `?` after every optional non-leaf segment.
1048///
1049/// This is the core helper for count/contains helpers that need to reconstruct
1050/// the path with correct optional chaining from the raw fixture field name.
1051///
1052/// Returns `(accessor_expr, has_optional)` where `has_optional` is true when
1053/// at least one `?.` was inserted.
1054fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1055    let resolved = field_resolver.resolve(field);
1056    let parts: Vec<&str> = resolved.split('.').collect();
1057
1058    // Build a set of optional prefix paths for O(1) lookup during the walk.
1059    // We track path_so_far incrementally.
1060    let mut out = result_var.to_string();
1061    let mut has_optional = false;
1062    let mut path_so_far = String::new();
1063    let total = parts.len();
1064    for (i, part) in parts.iter().enumerate() {
1065        let is_leaf = i == total - 1;
1066        if !path_so_far.is_empty() {
1067            path_so_far.push('.');
1068        }
1069        path_so_far.push_str(part);
1070        out.push('.');
1071        out.push_str(part);
1072        out.push_str("()");
1073        // Insert `?` after `()` for any non-leaf optional field so the next
1074        // member access becomes `?.`.
1075        if !is_leaf && field_resolver.is_optional(&path_so_far) {
1076            out.push('?');
1077            has_optional = true;
1078        }
1079    }
1080    (out, has_optional)
1081}
1082
1083/// Generate a `[String]?` expression for a `RustVec<RustString>` (or optional variant) field
1084/// so that `contains` membership checks work against plain Swift Strings.
1085///
1086/// The result is `Optional<[String]>` — callers should coalesce with `?? []`.
1087///
1088/// We use `?.map { $0.as_str().toString() }` because:
1089/// 1. Iterating a `RustVec<RustString>` yields `RustStringRef` (not `RustString`), which
1090///    only has `as_str()` but not `toString()` directly.
1091/// 2. The accessor may end with an `Optional<RustVec<RustString>>` (e.g. `sheet_names()` is
1092///    `Option<Vec<String>>` in Rust, which becomes `Optional<RustVec<RustString>>` in Swift).
1093/// 3. Optional chaining from parent `?.` already produces `Optional<RustVec<T>>`.
1094///
1095/// `?.map { $0.as_str().toString() }` converts each `RustStringRef` to a Swift `String`,
1096/// giving `[String]` wrapped in `Optional`. The `?? []` in callers coalesces nil to an empty
1097/// array.
1098fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1099    let Some(f) = field else {
1100        return format!("{result_var}.map {{ $0.as_str().toString() }}");
1101    };
1102    let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
1103    // Always use `?.map` — the array field (sheet_names, etc.) may itself return
1104    // Optional<RustVec<T>> even if not listed in fields_optional.
1105    format!("{accessor}?.map {{ $0.as_str().toString() }}")
1106}
1107
1108/// Generate a `.count` expression for an array field that may be nested inside optional parents.
1109///
1110/// Swift-bridge exposes all Rust fields as methods with `()`. When ancestor segments are
1111/// optional, we use `?.` chaining. The final count is coalesced with `?? 0` when there
1112/// are optional ancestors so the XCTAssert macro receives a non-optional `Int`.
1113fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1114    let Some(f) = field else {
1115        return format!("{result_var}.count");
1116    };
1117    let (accessor, has_optional) = swift_build_accessor(f, result_var, field_resolver);
1118    if has_optional {
1119        format!("{accessor}.count ?? 0")
1120    } else {
1121        format!("{accessor}.count")
1122    }
1123}
1124
1125/// Normalise a path by resolving `..` components without hitting the filesystem.
1126///
1127/// This mirrors what `std::fs::canonicalize` does but works on paths that do
1128/// not yet exist on disk (generated-file paths).  Only `..` traversals are
1129/// collapsed; `.` components are dropped; nothing else is changed.
1130fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
1131    let mut components = std::path::PathBuf::new();
1132    for component in path.components() {
1133        match component {
1134            std::path::Component::ParentDir => {
1135                // Pop the last pushed component if there is one that isn't
1136                // already a `..` (avoids over-collapsing `../../foo`).
1137                if !components.as_os_str().is_empty() {
1138                    components.pop();
1139                } else {
1140                    components.push(component);
1141                }
1142            }
1143            std::path::Component::CurDir => {}
1144            other => components.push(other),
1145        }
1146    }
1147    components
1148}
1149
1150/// Convert a `serde_json::Value` to a Swift literal string.
1151fn json_to_swift(value: &serde_json::Value) -> String {
1152    match value {
1153        serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
1154        serde_json::Value::Bool(b) => b.to_string(),
1155        serde_json::Value::Number(n) => n.to_string(),
1156        serde_json::Value::Null => "nil".to_string(),
1157        serde_json::Value::Array(arr) => {
1158            let items: Vec<String> = arr.iter().map(json_to_swift).collect();
1159            format!("[{}]", items.join(", "))
1160        }
1161        serde_json::Value::Object(_) => {
1162            let json_str = serde_json::to_string(value).unwrap_or_default();
1163            format!("\"{}\"", escape_swift(&json_str))
1164        }
1165    }
1166}
1167
1168/// Escape a string for embedding in a Swift double-quoted string literal.
1169fn escape_swift(s: &str) -> String {
1170    escape_swift_str(s)
1171}