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