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        // bytes args: fixture stores a fixture-relative path string. Generate
699        // setup that reads it into a Data and pushes each byte into a
700        // RustVec<UInt8>. Literal byte arrays inline the bytes; missing values
701        // produce an empty vec (or `nil` when optional).
702        if arg.arg_type == "bytes" {
703            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
704            let val = input.get(field);
705            match val {
706                None | Some(serde_json::Value::Null) if arg.optional => {
707                    if later_emits[idx] {
708                        parts.push("nil".to_string());
709                    }
710                }
711                None | Some(serde_json::Value::Null) => {
712                    let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
713                    setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
714                    parts.push(var_name);
715                }
716                Some(serde_json::Value::String(s)) => {
717                    let escaped = escape_swift(s);
718                    let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
719                    let data_var = format!("{}Data", arg.name.to_lower_camel_case());
720                    setup_lines.push(format!(
721                        "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
722                    ));
723                    setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
724                    setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
725                    parts.push(var_name);
726                }
727                Some(serde_json::Value::Array(arr)) => {
728                    let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
729                    setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
730                    for v in arr {
731                        if let Some(n) = v.as_u64() {
732                            setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
733                        }
734                    }
735                    parts.push(var_name);
736                }
737                Some(other) => {
738                    // Fallback: encode the JSON serialisation as UTF-8 bytes.
739                    let json_str = serde_json::to_string(other).unwrap_or_default();
740                    let escaped = escape_swift(&json_str);
741                    let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
742                    setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
743                    setup_lines.push(format!(
744                        "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
745                    ));
746                    parts.push(var_name);
747                }
748            }
749            continue;
750        }
751
752        // json_object "config" args: the swift-bridge wrapper requires an opaque
753        // `ExtractionConfig` (or sibling) instance, not a JSON string. Use the
754        // generated `extractionConfigFromJson(_:)` helper from RustBridge.
755        // Batch functions (batchExtract*) hardcode config internally — skip it.
756        let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
757        let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
758        if is_config_arg && !is_batch_fn {
759            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
760            let val = input.get(field);
761            let json_str = match val {
762                None | Some(serde_json::Value::Null) => "{}".to_string(),
763                Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
764            };
765            let escaped = escape_swift(&json_str);
766            let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
767            setup_lines.push(format!("let {var_name} = try extractionConfigFromJson(\"{escaped}\")"));
768            parts.push(var_name);
769            continue;
770        }
771
772        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
773        let val = input.get(field);
774        match val {
775            None | Some(serde_json::Value::Null) if arg.optional => {
776                // Optional arg with no fixture value: keep the slot with `nil`
777                // when a later arg will emit, so positional alignment matches
778                // the swift-bridge wrapper signature.
779                if later_emits[idx] {
780                    parts.push("nil".to_string());
781                }
782            }
783            None | Some(serde_json::Value::Null) => {
784                let default_val = match arg.arg_type.as_str() {
785                    "string" => "\"\"".to_string(),
786                    "int" | "integer" => "0".to_string(),
787                    "float" | "number" => "0.0".to_string(),
788                    "bool" | "boolean" => "false".to_string(),
789                    _ => "nil".to_string(),
790                };
791                parts.push(default_val);
792            }
793            Some(v) => {
794                parts.push(json_to_swift(v));
795            }
796        }
797    }
798
799    (setup_lines, parts.join(", "))
800}
801
802fn render_assertion(
803    out: &mut String,
804    assertion: &Assertion,
805    result_var: &str,
806    field_resolver: &FieldResolver,
807    result_is_simple: bool,
808    result_is_array: bool,
809    enum_fields: &HashSet<String>,
810) {
811    // Skip assertions on fields that don't exist on the result type.
812    if let Some(f) = &assertion.field {
813        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
814            let _ = writeln!(out, "        // skipped: field '{{f}}' not available on result type");
815            return;
816        }
817    }
818
819    // Skip assertions that traverse a tagged-union variant boundary.
820    // In Swift, FormatMetadata and similar enum-backed opaque types are exposed as
821    // plain classes by swift-bridge — variant accessor methods (e.g., `.excel()`)
822    // are not generated, so such assertions cannot be expressed.
823    if let Some(f) = &assertion.field {
824        if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
825            let _ = writeln!(
826                out,
827                "        // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
828            );
829            return;
830        }
831    }
832
833    // Determine if this field is an enum type.
834    let field_is_enum = assertion
835        .field
836        .as_deref()
837        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
838
839    let field_expr = if result_is_simple {
840        result_var.to_string()
841    } else {
842        match &assertion.field {
843            Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
844            _ => result_var.to_string(),
845        }
846    };
847
848    // For enum fields, use .rawValue to get the string value.
849    // For other fields: swift-bridge returns all Rust `String` fields as `RustString`.
850    // We add .toString() here so string assertions (contains, hasPrefix, etc.) work.
851    // Non-string opaque fields (DocumentStructure, etc.) should not appear in string
852    // assertions — the fixture schema controls which assertions apply to which fields.
853    let string_expr = if field_is_enum {
854        format!("{field_expr}.rawValue")
855    } else {
856        format!("{field_expr}.toString()")
857    };
858
859    match assertion.assertion_type.as_str() {
860        "equals" => {
861            if let Some(expected) = &assertion.value {
862                let swift_val = json_to_swift(expected);
863                if expected.is_string() {
864                    // For optional strings (String?), use ?? to coalesce before trimming.
865                    // `.toString()` converts RustString → Swift String before calling
866                    // `.trimmingCharacters`, which requires a concrete String type.
867                    let field_is_optional = assertion
868                        .field
869                        .as_deref()
870                        .is_some_and(|f| field_resolver.is_optional(f));
871                    let trim_expr = if field_is_optional {
872                        format!("(({field_expr})?.toString() ?? \"\").trimmingCharacters(in: .whitespaces)")
873                    } else {
874                        // string_expr already has .toString() appended; just trim.
875                        format!("{string_expr}.trimmingCharacters(in: .whitespaces)")
876                    };
877                    let _ = writeln!(out, "        XCTAssertEqual({trim_expr}, {swift_val})");
878                } else {
879                    let _ = writeln!(out, "        XCTAssertEqual({field_expr}, {swift_val})");
880                }
881            }
882        }
883        "contains" => {
884            if let Some(expected) = &assertion.value {
885                let swift_val = json_to_swift(expected);
886                // When the root result IS the array (result_is_simple + result_is_array) and
887                // there is no field path, check array membership via map+contains.
888                let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
889                if result_is_simple && result_is_array && no_field {
890                    // RustVec<RustString> iteration yields RustStringRef (no `toString()`);
891                    // use `.as_str().toString()` to convert each element to a Swift String.
892                    let _ = writeln!(
893                        out,
894                        "        XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
895                    );
896                } else {
897                    // For array fields (RustVec<RustString>), check membership via map+contains.
898                    let field_is_array = assertion
899                        .field
900                        .as_deref()
901                        .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
902                    if field_is_array {
903                        let contains_expr =
904                            swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
905                        let _ = writeln!(
906                            out,
907                            "        XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
908                        );
909                    } else {
910                        let _ = writeln!(
911                            out,
912                            "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
913                        );
914                    }
915                }
916            }
917        }
918        "contains_all" => {
919            if let Some(values) = &assertion.values {
920                // For array fields (RustVec<RustString>), check membership via map+contains.
921                let field_is_array = assertion
922                    .field
923                    .as_deref()
924                    .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
925                if field_is_array {
926                    let contains_expr =
927                        swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
928                    for val in values {
929                        let swift_val = json_to_swift(val);
930                        let _ = writeln!(
931                            out,
932                            "        XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
933                        );
934                    }
935                } else {
936                    for val in values {
937                        let swift_val = json_to_swift(val);
938                        let _ = writeln!(
939                            out,
940                            "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
941                        );
942                    }
943                }
944            }
945        }
946        "not_contains" => {
947            if let Some(expected) = &assertion.value {
948                let swift_val = json_to_swift(expected);
949                let _ = writeln!(
950                    out,
951                    "        XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
952                );
953            }
954        }
955        "not_empty" => {
956            // For optional fields (Optional<T>), check that the value is non-nil.
957            // For string fields, convert to Swift String and check .isEmpty.
958            let field_is_optional = assertion
959                .field
960                .as_deref()
961                .is_some_and(|f| field_resolver.is_optional(f));
962            if field_is_optional {
963                let _ = writeln!(out, "        XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
964            } else {
965                // string_expr has .toString() appended; .isEmpty works on Swift String.
966                let _ = writeln!(
967                    out,
968                    "        XCTAssertFalse({string_expr}.isEmpty, \"expected non-empty value\")"
969                );
970            }
971        }
972        "is_empty" => {
973            let field_is_optional = assertion
974                .field
975                .as_deref()
976                .is_some_and(|f| field_resolver.is_optional(f));
977            if field_is_optional {
978                let _ = writeln!(out, "        XCTAssertNil({field_expr}, \"expected nil value\")");
979            } else {
980                let _ = writeln!(
981                    out,
982                    "        XCTAssertTrue({string_expr}.isEmpty, \"expected empty value\")"
983                );
984            }
985        }
986        "contains_any" => {
987            if let Some(values) = &assertion.values {
988                let checks: Vec<String> = values
989                    .iter()
990                    .map(|v| {
991                        let swift_val = json_to_swift(v);
992                        format!("{string_expr}.contains({swift_val})")
993                    })
994                    .collect();
995                let joined = checks.join(" || ");
996                let _ = writeln!(
997                    out,
998                    "        XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
999                );
1000            }
1001        }
1002        "greater_than" => {
1003            if let Some(val) = &assertion.value {
1004                let swift_val = json_to_swift(val);
1005                // For optional numeric fields, coalesce to 0 before comparing.
1006                let field_is_optional = assertion
1007                    .field
1008                    .as_deref()
1009                    .is_some_and(|f| field_resolver.is_optional(f));
1010                let compare_expr = if field_is_optional {
1011                    format!("({field_expr} ?? 0)")
1012                } else {
1013                    field_expr.clone()
1014                };
1015                let _ = writeln!(out, "        XCTAssertGreaterThan({compare_expr}, {swift_val})");
1016            }
1017        }
1018        "less_than" => {
1019            if let Some(val) = &assertion.value {
1020                let swift_val = json_to_swift(val);
1021                let field_is_optional = assertion
1022                    .field
1023                    .as_deref()
1024                    .is_some_and(|f| field_resolver.is_optional(f));
1025                let compare_expr = if field_is_optional {
1026                    format!("({field_expr} ?? 0)")
1027                } else {
1028                    field_expr.clone()
1029                };
1030                let _ = writeln!(out, "        XCTAssertLessThan({compare_expr}, {swift_val})");
1031            }
1032        }
1033        "greater_than_or_equal" => {
1034            if let Some(val) = &assertion.value {
1035                let swift_val = json_to_swift(val);
1036                // For optional numeric fields, coalesce to 0 before comparing.
1037                let field_is_optional = assertion
1038                    .field
1039                    .as_deref()
1040                    .is_some_and(|f| field_resolver.is_optional(f));
1041                let compare_expr = if field_is_optional {
1042                    format!("({field_expr} ?? 0)")
1043                } else {
1044                    field_expr.clone()
1045                };
1046                let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
1047            }
1048        }
1049        "less_than_or_equal" => {
1050            if let Some(val) = &assertion.value {
1051                let swift_val = json_to_swift(val);
1052                let field_is_optional = assertion
1053                    .field
1054                    .as_deref()
1055                    .is_some_and(|f| field_resolver.is_optional(f));
1056                let compare_expr = if field_is_optional {
1057                    format!("({field_expr} ?? 0)")
1058                } else {
1059                    field_expr.clone()
1060                };
1061                let _ = writeln!(out, "        XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
1062            }
1063        }
1064        "starts_with" => {
1065            if let Some(expected) = &assertion.value {
1066                let swift_val = json_to_swift(expected);
1067                let _ = writeln!(
1068                    out,
1069                    "        XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1070                );
1071            }
1072        }
1073        "ends_with" => {
1074            if let Some(expected) = &assertion.value {
1075                let swift_val = json_to_swift(expected);
1076                let _ = writeln!(
1077                    out,
1078                    "        XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1079                );
1080            }
1081        }
1082        "min_length" => {
1083            if let Some(val) = &assertion.value {
1084                if let Some(n) = val.as_u64() {
1085                    // Use string_expr.count: for RustString fields string_expr already has
1086                    // .toString() appended, giving a Swift String whose .count is character count.
1087                    let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1088                }
1089            }
1090        }
1091        "max_length" => {
1092            if let Some(val) = &assertion.value {
1093                if let Some(n) = val.as_u64() {
1094                    let _ = writeln!(out, "        XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1095                }
1096            }
1097        }
1098        "count_min" => {
1099            if let Some(val) = &assertion.value {
1100                if let Some(n) = val.as_u64() {
1101                    // For fields nested inside an optional parent (e.g. document.nodes where
1102                    // document is Optional), the accessor generates `result.document().nodes()`
1103                    // which doesn't compile in Swift without optional chaining.
1104                    let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1105                    let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1106                }
1107            }
1108        }
1109        "count_equals" => {
1110            if let Some(val) = &assertion.value {
1111                if let Some(n) = val.as_u64() {
1112                    let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1113                    let _ = writeln!(out, "        XCTAssertEqual({count_expr}, {n})");
1114                }
1115            }
1116        }
1117        "is_true" => {
1118            let _ = writeln!(out, "        XCTAssertTrue({field_expr})");
1119        }
1120        "is_false" => {
1121            let _ = writeln!(out, "        XCTAssertFalse({field_expr})");
1122        }
1123        "matches_regex" => {
1124            if let Some(expected) = &assertion.value {
1125                let swift_val = json_to_swift(expected);
1126                let _ = writeln!(
1127                    out,
1128                    "        XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1129                );
1130            }
1131        }
1132        "not_error" => {
1133            // Already handled by the call succeeding without exception.
1134        }
1135        "error" => {
1136            // Handled at the test method level.
1137        }
1138        "method_result" => {
1139            let _ = writeln!(out, "        // method_result assertions not yet implemented for Swift");
1140        }
1141        other => {
1142            panic!("Swift e2e generator: unsupported assertion type: {other}");
1143        }
1144    }
1145}
1146
1147/// Build a Swift accessor path for the given fixture field, inserting `()` on
1148/// every segment and `?` after every optional non-leaf segment.
1149///
1150/// This is the core helper for count/contains helpers that need to reconstruct
1151/// the path with correct optional chaining from the raw fixture field name.
1152///
1153/// Returns `(accessor_expr, has_optional)` where `has_optional` is true when
1154/// at least one `?.` was inserted.
1155fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1156    let resolved = field_resolver.resolve(field);
1157    let parts: Vec<&str> = resolved.split('.').collect();
1158
1159    // Build a set of optional prefix paths for O(1) lookup during the walk.
1160    // We track path_so_far incrementally.
1161    let mut out = result_var.to_string();
1162    let mut has_optional = false;
1163    let mut path_so_far = String::new();
1164    let total = parts.len();
1165    for (i, part) in parts.iter().enumerate() {
1166        let is_leaf = i == total - 1;
1167        if !path_so_far.is_empty() {
1168            path_so_far.push('.');
1169        }
1170        path_so_far.push_str(part);
1171        out.push('.');
1172        out.push_str(part);
1173        out.push_str("()");
1174        // Insert `?` after `()` for any non-leaf optional field so the next
1175        // member access becomes `?.`.
1176        if !is_leaf && field_resolver.is_optional(&path_so_far) {
1177            out.push('?');
1178            has_optional = true;
1179        }
1180    }
1181    (out, has_optional)
1182}
1183
1184/// Generate a `[String]?` expression for a `RustVec<RustString>` (or optional variant) field
1185/// so that `contains` membership checks work against plain Swift Strings.
1186///
1187/// The result is `Optional<[String]>` — callers should coalesce with `?? []`.
1188///
1189/// We use `?.map { $0.as_str().toString() }` because:
1190/// 1. Iterating a `RustVec<RustString>` yields `RustStringRef` (not `RustString`), which
1191///    only has `as_str()` but not `toString()` directly.
1192/// 2. The accessor may end with an `Optional<RustVec<RustString>>` (e.g. `sheet_names()` is
1193///    `Option<Vec<String>>` in Rust, which becomes `Optional<RustVec<RustString>>` in Swift).
1194/// 3. Optional chaining from parent `?.` already produces `Optional<RustVec<T>>`.
1195///
1196/// `?.map { $0.as_str().toString() }` converts each `RustStringRef` to a Swift `String`,
1197/// giving `[String]` wrapped in `Optional`. The `?? []` in callers coalesces nil to an empty
1198/// array.
1199fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1200    let Some(f) = field else {
1201        return format!("{result_var}.map {{ $0.as_str().toString() }}");
1202    };
1203    let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
1204    // Always use `?.map` — the array field (sheet_names, etc.) may itself return
1205    // Optional<RustVec<T>> even if not listed in fields_optional.
1206    format!("{accessor}?.map {{ $0.as_str().toString() }}")
1207}
1208
1209/// Generate a `.count` expression for an array field that may be nested inside optional parents.
1210///
1211/// Swift-bridge exposes all Rust fields as methods with `()`. When ancestor segments are
1212/// optional, we use `?.` chaining. The final count is coalesced with `?? 0` when there
1213/// are optional ancestors so the XCTAssert macro receives a non-optional `Int`.
1214fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1215    let Some(f) = field else {
1216        return format!("{result_var}.count");
1217    };
1218    let (accessor, has_optional) = swift_build_accessor(f, result_var, field_resolver);
1219    if has_optional {
1220        format!("{accessor}.count ?? 0")
1221    } else {
1222        format!("{accessor}.count")
1223    }
1224}
1225
1226/// Normalise a path by resolving `..` components without hitting the filesystem.
1227///
1228/// This mirrors what `std::fs::canonicalize` does but works on paths that do
1229/// not yet exist on disk (generated-file paths).  Only `..` traversals are
1230/// collapsed; `.` components are dropped; nothing else is changed.
1231fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
1232    let mut components = std::path::PathBuf::new();
1233    for component in path.components() {
1234        match component {
1235            std::path::Component::ParentDir => {
1236                // Pop the last pushed component if there is one that isn't
1237                // already a `..` (avoids over-collapsing `../../foo`).
1238                if !components.as_os_str().is_empty() {
1239                    components.pop();
1240                } else {
1241                    components.push(component);
1242                }
1243            }
1244            std::path::Component::CurDir => {}
1245            other => components.push(other),
1246        }
1247    }
1248    components
1249}
1250
1251/// Convert a `serde_json::Value` to a Swift literal string.
1252fn json_to_swift(value: &serde_json::Value) -> String {
1253    match value {
1254        serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
1255        serde_json::Value::Bool(b) => b.to_string(),
1256        serde_json::Value::Number(n) => n.to_string(),
1257        serde_json::Value::Null => "nil".to_string(),
1258        serde_json::Value::Array(arr) => {
1259            let items: Vec<String> = arr.iter().map(json_to_swift).collect();
1260            format!("[{}]", items.join(", "))
1261        }
1262        serde_json::Value::Object(_) => {
1263            let json_str = serde_json::to_string(value).unwrap_or_default();
1264            format!("\"{}\"", escape_swift(&json_str))
1265        }
1266    }
1267}
1268
1269/// Escape a string for embedding in a Swift double-quoted string literal.
1270fn escape_swift(s: &str) -> String {
1271    escape_swift_str(s)
1272}