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