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        // Resolve client_factory override for swift (enables client-instance dispatch).
122        let client_factory: Option<&str> = overrides.and_then(|o| o.client_factory.as_deref());
123
124        // One test file per fixture group.
125        for group in groups {
126            let active: Vec<&Fixture> = group
127                .fixtures
128                .iter()
129                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
130                .collect();
131
132            if active.is_empty() {
133                continue;
134            }
135
136            let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
137            let filename = format!("{class_name}.swift");
138            let content = render_test_file(
139                &group.category,
140                &active,
141                e2e_config,
142                module_name,
143                &class_name,
144                &function_name,
145                result_var,
146                &e2e_config.call.args,
147                &field_resolver,
148                result_is_simple,
149                &e2e_config.fields_enum,
150                client_factory,
151            );
152            files.push(GeneratedFile {
153                path: tests_base
154                    .join("Tests")
155                    .join(format!("{module_name}Tests"))
156                    .join(filename),
157                content,
158                generated_header: true,
159            });
160        }
161
162        Ok(files)
163    }
164
165    fn language_name(&self) -> &'static str {
166        "swift"
167    }
168}
169
170// ---------------------------------------------------------------------------
171// Rendering
172// ---------------------------------------------------------------------------
173
174fn render_package_swift(
175    module_name: &str,
176    registry_url: &str,
177    pkg_path: &str,
178    pkg_version: &str,
179    dep_mode: crate::config::DependencyMode,
180) -> String {
181    let min_macos = toolchain::SWIFT_MIN_MACOS;
182
183    // For local deps SwiftPM identity = last path component (e.g. "../../packages/swift" → "swift").
184    // For registry deps identity is inferred from the URL.
185    // Use explicit .product(name:package:) to avoid ambiguity under tools-version 6.0.
186    let (dep_block, product_dep) = match dep_mode {
187        crate::config::DependencyMode::Registry => {
188            let dep = format!(r#"        .package(url: "{registry_url}", from: "{pkg_version}")"#);
189            let pkg_id = registry_url
190                .trim_end_matches('/')
191                .trim_end_matches(".git")
192                .split('/')
193                .next_back()
194                .unwrap_or(module_name);
195            let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
196            (dep, prod)
197        }
198        crate::config::DependencyMode::Local => {
199            let dep = format!(r#"        .package(path: "{pkg_path}")"#);
200            let pkg_id = pkg_path
201                .trim_end_matches('/')
202                .split('/')
203                .next_back()
204                .unwrap_or(module_name);
205            let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
206            (dep, prod)
207        }
208    };
209    // SwiftPM platform enums use the major version only (.v13, .v14, ...);
210    // strip patch components to match the scaffold's `Package.swift`.
211    let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
212    // iOS (.v14) is always included — swift-bridge supports both macOS and iOS targets
213    // and the generated Package.swift is used as a CI reference for both platforms.
214    format!(
215        r#"// swift-tools-version: 6.0
216import PackageDescription
217
218let package = Package(
219    name: "E2eSwift",
220    platforms: [
221        .macOS(.v{min_macos_major}),
222        .iOS(.v14),
223    ],
224    dependencies: [
225{dep_block},
226    ],
227    targets: [
228        .testTarget(
229            name: "{module_name}Tests",
230            dependencies: [{product_dep}]
231        ),
232    ]
233)
234"#
235    )
236}
237
238#[allow(clippy::too_many_arguments)]
239fn render_test_file(
240    category: &str,
241    fixtures: &[&Fixture],
242    e2e_config: &E2eConfig,
243    module_name: &str,
244    class_name: &str,
245    function_name: &str,
246    result_var: &str,
247    args: &[crate::config::ArgMapping],
248    field_resolver: &FieldResolver,
249    result_is_simple: bool,
250    enum_fields: &HashSet<String>,
251    client_factory: Option<&str>,
252) -> String {
253    // Detect whether any fixture in this group uses a file_path or bytes arg — if so
254    // the test class chdir's to <repo>/test_documents at setUp time so the
255    // fixture-relative paths in test bodies (e.g. "docx/fake.docx") resolve correctly.
256    // The Swift binding's `extractBytes`/`extractFile` e2e wrappers consult
257    // `FIXTURES_DIR` first, otherwise resolve against the current directory.
258    // Mirrors the Ruby/Python conftest pattern that chdirs to test_documents.
259    let needs_chdir = fixtures.iter().any(|f| {
260        let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
261        call_config
262            .args
263            .iter()
264            .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
265    });
266
267    let mut out = String::new();
268    out.push_str(&hash::header(CommentStyle::DoubleSlash));
269    let _ = writeln!(out, "import XCTest");
270    let _ = writeln!(out, "import Foundation");
271    let _ = writeln!(out, "import {module_name}");
272    let _ = writeln!(out, "import RustBridge");
273    let _ = writeln!(out);
274    let _ = writeln!(out, "/// E2e tests for category: {category}.");
275    let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
276
277    if needs_chdir {
278        // Chdir once at class setUp so all fixture file_path arguments resolve relative
279        // to the repository's test_documents directory.
280        //
281        // #filePath = <repo>/packages/swift/Tests/<Module>Tests/<Class>.swift
282        // 5 deletingLastPathComponent() calls climb to the repo root before appending
283        // "test_documents". Mirrors the Ruby/Python conftest pattern that chdirs to
284        // test_documents.
285        let _ = writeln!(out, "    override class func setUp() {{");
286        let _ = writeln!(out, "        super.setUp()");
287        let _ = writeln!(out, "        let _testDocs = URL(fileURLWithPath: #filePath)");
288        let _ = writeln!(out, "            .deletingLastPathComponent() // <Module>Tests/");
289        let _ = writeln!(out, "            .deletingLastPathComponent() // Tests/");
290        let _ = writeln!(out, "            .deletingLastPathComponent() // swift/");
291        let _ = writeln!(out, "            .deletingLastPathComponent() // packages/");
292        let _ = writeln!(out, "            .deletingLastPathComponent() // <repo root>");
293        let _ = writeln!(
294            out,
295            "            .appendingPathComponent(\"{}\")",
296            e2e_config.test_documents_dir
297        );
298        let _ = writeln!(
299            out,
300            "        if FileManager.default.fileExists(atPath: _testDocs.path) {{"
301        );
302        let _ = writeln!(
303            out,
304            "            FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
305        );
306        let _ = writeln!(out, "        }}");
307        let _ = writeln!(out, "    }}");
308        let _ = writeln!(out);
309    }
310
311    for fixture in fixtures {
312        if fixture.is_http_test() {
313            render_http_test_method(&mut out, fixture);
314        } else {
315            render_test_method(
316                &mut out,
317                fixture,
318                e2e_config,
319                function_name,
320                result_var,
321                args,
322                field_resolver,
323                result_is_simple,
324                enum_fields,
325                client_factory,
326            );
327        }
328        let _ = writeln!(out);
329    }
330
331    let _ = writeln!(out, "}}");
332    out
333}
334
335// ---------------------------------------------------------------------------
336// HTTP test rendering — TestClientRenderer impl + thin driver wrapper
337// ---------------------------------------------------------------------------
338
339/// Renderer that emits XCTest `func test...() throws` methods using `URLSession`
340/// against the mock server (`ProcessInfo.processInfo.environment["MOCK_SERVER_URL"]`).
341struct SwiftTestClientRenderer;
342
343impl client::TestClientRenderer for SwiftTestClientRenderer {
344    fn language_name(&self) -> &'static str {
345        "swift"
346    }
347
348    fn sanitize_test_name(&self, id: &str) -> String {
349        // Swift test methods are `func testFoo()` — upper-camel-case after "test".
350        sanitize_ident(id).to_upper_camel_case()
351    }
352
353    /// Emit `func test{FnName}() throws {` (or a skip stub when the fixture is skipped).
354    ///
355    /// XCTest has no first-class skip annotation prior to Swift Testing (`@Test`).
356    /// For skipped fixtures we emit `try XCTSkipIf(true, reason)` inside the
357    /// function body so XCTest records them as skipped rather than omitting them.
358    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
359        let _ = writeln!(out, "    /// {description}");
360        let _ = writeln!(out, "    func test{fn_name}() throws {{");
361        if let Some(reason) = skip_reason {
362            let escaped = escape_swift(reason);
363            let _ = writeln!(out, "        try XCTSkipIf(true, \"{escaped}\")");
364        }
365    }
366
367    fn render_test_close(&self, out: &mut String) {
368        let _ = writeln!(out, "    }}");
369    }
370
371    /// Emit a synchronous `URLSession` round-trip to the mock server.
372    ///
373    /// `ProcessInfo.processInfo.environment["MOCK_SERVER_URL"]!` provides the base
374    /// URL; the fixture path is appended directly.  The call uses a semaphore so the
375    /// generated test body stays synchronous (compatible with `throws` functions —
376    /// no `async` XCTest support needed).
377    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
378        let method = ctx.method.to_uppercase();
379        let fixture_path = escape_swift(ctx.path);
380
381        let _ = writeln!(
382            out,
383            "        let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
384        );
385        let _ = writeln!(
386            out,
387            "        var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
388        );
389        let _ = writeln!(out, "        _req.httpMethod = \"{method}\"");
390
391        // Headers
392        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
393        header_pairs.sort_by_key(|(k, _)| k.as_str());
394        for (k, v) in &header_pairs {
395            let expanded_v = expand_fixture_templates(v);
396            let ek = escape_swift(k);
397            let ev = escape_swift(&expanded_v);
398            let _ = writeln!(out, "        _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
399        }
400
401        // Body
402        if let Some(body) = ctx.body {
403            let json_str = serde_json::to_string(body).unwrap_or_default();
404            let escaped_body = escape_swift(&json_str);
405            let _ = writeln!(out, "        _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
406            let _ = writeln!(
407                out,
408                "        _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
409            );
410        }
411
412        let _ = writeln!(out, "        var {}: HTTPURLResponse?", ctx.response_var);
413        let _ = writeln!(out, "        var _responseData: Data?");
414        let _ = writeln!(out, "        let _sema = DispatchSemaphore(value: 0)");
415        let _ = writeln!(
416            out,
417            "        URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
418        );
419        let _ = writeln!(out, "            {} = resp as? HTTPURLResponse", ctx.response_var);
420        let _ = writeln!(out, "            _responseData = data");
421        let _ = writeln!(out, "            _sema.signal()");
422        let _ = writeln!(out, "        }}.resume()");
423        let _ = writeln!(out, "        _sema.wait()");
424        let _ = writeln!(out, "        let _resp = try XCTUnwrap({})", ctx.response_var);
425    }
426
427    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
428        let _ = writeln!(out, "        XCTAssertEqual(_resp.statusCode, {status})");
429    }
430
431    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
432        let lower_name = name.to_lowercase();
433        let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
434        match expected {
435            "<<present>>" => {
436                let _ = writeln!(out, "        XCTAssertNotNil({header_expr})");
437            }
438            "<<absent>>" => {
439                let _ = writeln!(out, "        XCTAssertNil({header_expr})");
440            }
441            "<<uuid>>" => {
442                let _ = writeln!(out, "        let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
443                let _ = writeln!(
444                    out,
445                    "        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))"
446                );
447            }
448            exact => {
449                let escaped = escape_swift(exact);
450                let _ = writeln!(out, "        XCTAssertEqual({header_expr}, \"{escaped}\")");
451            }
452        }
453    }
454
455    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
456        if let serde_json::Value::String(s) = expected {
457            let escaped = escape_swift(s);
458            let _ = writeln!(
459                out,
460                "        let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
461            );
462            let _ = writeln!(
463                out,
464                "        XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
465            );
466        } else {
467            let json_str = serde_json::to_string(expected).unwrap_or_default();
468            let escaped = escape_swift(&json_str);
469            let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
470            let _ = writeln!(
471                out,
472                "        let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
473            );
474            let _ = writeln!(
475                out,
476                "        let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
477            );
478            let _ = writeln!(
479                out,
480                "        XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
481            );
482        }
483    }
484
485    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
486        if let Some(obj) = expected.as_object() {
487            let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
488            let _ = writeln!(
489                out,
490                "        let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
491            );
492            for (key, val) in obj {
493                let escaped_key = escape_swift(key);
494                let swift_val = json_to_swift(val);
495                let _ = writeln!(
496                    out,
497                    "        XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
498                );
499            }
500        }
501    }
502
503    fn render_assert_validation_errors(
504        &self,
505        out: &mut String,
506        _response_var: &str,
507        errors: &[ValidationErrorExpectation],
508    ) {
509        let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
510        let _ = writeln!(
511            out,
512            "        let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
513        );
514        let _ = writeln!(
515            out,
516            "        let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
517        );
518        for ve in errors {
519            let escaped_msg = escape_swift(&ve.msg);
520            let _ = writeln!(
521                out,
522                "        XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
523            );
524        }
525    }
526}
527
528/// Render an XCTest method for an HTTP server fixture via the shared driver.
529///
530/// HTTP 101 (WebSocket upgrade) is emitted as a skip stub because `URLSession`
531/// cannot handle Upgrade responses.
532fn render_http_test_method(out: &mut String, fixture: &Fixture) {
533    let Some(http) = &fixture.http else {
534        return;
535    };
536
537    // HTTP 101 (WebSocket upgrade) — URLSession cannot handle upgrade responses.
538    if http.expected_response.status_code == 101 {
539        let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
540        let description = fixture.description.replace('"', "\\\"");
541        let _ = writeln!(out, "    /// {description}");
542        let _ = writeln!(out, "    func test{method_name}() throws {{");
543        let _ = writeln!(
544            out,
545            "        try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
546        );
547        let _ = writeln!(out, "    }}");
548        return;
549    }
550
551    client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
552}
553
554// ---------------------------------------------------------------------------
555// Function-call test rendering
556// ---------------------------------------------------------------------------
557
558#[allow(clippy::too_many_arguments)]
559fn render_test_method(
560    out: &mut String,
561    fixture: &Fixture,
562    e2e_config: &E2eConfig,
563    _function_name: &str,
564    _result_var: &str,
565    _args: &[crate::config::ArgMapping],
566    field_resolver: &FieldResolver,
567    result_is_simple: bool,
568    enum_fields: &HashSet<String>,
569    global_client_factory: Option<&str>,
570) {
571    // Resolve per-fixture call config.
572    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
573    let lang = "swift";
574    let call_overrides = call_config.overrides.get(lang);
575    let function_name = call_overrides
576        .and_then(|o| o.function.as_ref())
577        .cloned()
578        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
579    // Per-call client_factory takes precedence over the global one.
580    let client_factory: Option<&str> = call_overrides
581        .and_then(|o| o.client_factory.as_deref())
582        .or(global_client_factory);
583    let result_var = &call_config.result_var;
584    let args = &call_config.args;
585    // Per-call flags: base call flag OR per-language override OR global flag.
586    let result_is_simple =
587        call_config.result_is_simple || call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
588    let result_is_array = call_config.result_is_array;
589
590    let method_name = fixture.id.to_upper_camel_case();
591    let description = &fixture.description;
592    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
593    let is_async = call_config.r#async;
594
595    // Streaming fixtures: drain the async sequence into a `chunks` array after the call.
596    // Also trigger when any assertion references a streaming virtual field (e.g. empty_stream
597    // has stream_chunks:[] so is_streaming_mock() returns false, but assertions still reference
598    // `chunks`/`stream_content` which require the collect snippet).
599    let is_streaming = fixture.is_streaming_mock()
600        || fixture.assertions.iter().any(|a| {
601            a.field
602                .as_deref()
603                .is_some_and(|f| !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f))
604        });
605    let collect_snippet_opt = if is_streaming && !expects_error {
606        crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(lang, result_var, "chunks")
607    } else {
608        None
609    };
610    // When swift has streaming-virtual-field assertions but no collect snippet
611    // is available (the swift-bridge surface does not yet expose a typed
612    // `chatStream` async sequence we can drain into a typed
613    // `[ChatCompletionChunk]`), emit a skip stub rather than reference an
614    // undefined `chunks` local in the assertion expressions. This keeps the
615    // swift test target compiling while the binding catches up.
616    if is_streaming && !expects_error && collect_snippet_opt.is_none() {
617        if is_async {
618            let _ = writeln!(out, "    func test{method_name}() async throws {{");
619        } else {
620            let _ = writeln!(out, "    func test{method_name}() throws {{");
621        }
622        let _ = writeln!(out, "        // {description}");
623        let _ = writeln!(
624            out,
625            "        try XCTSkipIf(true, \"swift: streaming chunk collection is not yet supported via the swift-bridge surface (fixture: {})\")",
626            fixture.id
627        );
628        let _ = writeln!(out, "    }}");
629        return;
630    }
631    let collect_snippet = collect_snippet_opt.unwrap_or_default();
632
633    // Detect whether this call has any json_object args that cannot be constructed
634    // in Swift — swift-bridge opaque types do not provide a fromJson initialiser.
635    // When such args exist and no `options_via` is configured for swift, emit a
636    // skip stub so the test compiles but is recorded as skipped rather than
637    // generating invalid code that passes `nil` or a string literal where a
638    // strongly-typed request object is required.
639    let has_unresolvable_json_object_arg = {
640        let options_via = call_overrides.and_then(|o| o.options_via.as_deref());
641        options_via.is_none() && args.iter().any(|a| a.arg_type == "json_object" && a.name != "config")
642    };
643
644    if has_unresolvable_json_object_arg {
645        if is_async {
646            let _ = writeln!(out, "    func test{method_name}() async throws {{");
647        } else {
648            let _ = writeln!(out, "    func test{method_name}() throws {{");
649        }
650        let _ = writeln!(out, "        // {description}");
651        let _ = writeln!(
652            out,
653            "        try XCTSkipIf(true, \"swift: json_object request construction requires options_via configuration (fixture: {})\");",
654            fixture.id
655        );
656        let _ = writeln!(out, "    }}");
657        return;
658    }
659
660    // Resolve extra_args from per-call swift overrides (e.g. `nil` for optional
661    // query-param arguments on list_files/list_batches that have no fixture-level
662    // input field).
663    let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
664
665    // Merge per-call enum_fields keys into the effective enum set so that
666    // fields like "status" (BatchStatus, BatchObject) are treated as enum-typed
667    // even when they are not globally listed in fields_enum (they are context-
668    // dependent — BatchStatus on BatchObject but plain String on ResponseObject).
669    let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
670        let per_call = call_overrides.map(|o| &o.enum_fields);
671        if let Some(pc) = per_call {
672            if !pc.is_empty() {
673                let mut merged = enum_fields.clone();
674                merged.extend(pc.keys().cloned());
675                std::borrow::Cow::Owned(merged)
676            } else {
677                std::borrow::Cow::Borrowed(enum_fields)
678            }
679        } else {
680            std::borrow::Cow::Borrowed(enum_fields)
681        }
682    };
683
684    let options_via_str: Option<&str> = call_overrides.and_then(|o| o.options_via.as_deref());
685    let options_type_str: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
686    let (setup_lines, args_str) = build_args_and_setup(
687        &fixture.input,
688        args,
689        &fixture.id,
690        fixture.has_host_root_route(),
691        &function_name,
692        options_via_str,
693        options_type_str,
694    );
695
696    // Append extra_args to the argument list.
697    let args_str = if extra_args.is_empty() {
698        args_str
699    } else if args_str.is_empty() {
700        extra_args.join(", ")
701    } else {
702        format!("{args_str}, {}", extra_args.join(", "))
703    };
704
705    // When a client_factory is set, dispatch via a client instance:
706    //   let client = try <FactoryType>(apiKey: "test-key", baseUrl: <mock_url>)
707    //   try await client.<method>(args)
708    // Otherwise fall back to free-function call (Kreuzberg / non-client-factory libraries).
709    let has_mock = fixture.mock_response.is_some();
710    let (call_setup, call_expr) = if let Some(_factory) = client_factory {
711        let env_key = format!("MOCK_SERVER_{}", fixture.id.to_ascii_uppercase().replace('-', "_"));
712        let mock_url = if fixture.has_host_root_route() {
713            format!(
714                "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\")",
715                fixture.id
716            )
717        } else {
718            format!(
719                "ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\"",
720                fixture.id
721            )
722        };
723        let client_constructor = if has_mock {
724            format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
725        } else {
726            // Live API: check for api_key_var; if not present use mock URL anyway.
727            if let Some(env_var) = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()) {
728                format!(
729                    "let _apiKey = ProcessInfo.processInfo.environment[\"{env_var}\"]\n        \
730                     let _baseUrl: String? = _apiKey != nil ? nil : {mock_url}\n        \
731                     let _client = try DefaultClient(apiKey: _apiKey ?? \"test-key\", baseUrl: _baseUrl)"
732                )
733            } else {
734                format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
735            }
736        };
737        let expr = if is_async {
738            format!("try await _client.{function_name}({args_str})")
739        } else {
740            format!("try _client.{function_name}({args_str})")
741        };
742        (Some(client_constructor), expr)
743    } else {
744        // Free-function call (no client_factory).
745        let expr = if is_async {
746            format!("try await {function_name}({args_str})")
747        } else {
748            format!("try {function_name}({args_str})")
749        };
750        (None, expr)
751    };
752    // For backwards compatibility: qualified_function_name unused when client_factory is set.
753    let _ = function_name;
754
755    if is_async {
756        let _ = writeln!(out, "    func test{method_name}() async throws {{");
757    } else {
758        let _ = writeln!(out, "    func test{method_name}() throws {{");
759    }
760    let _ = writeln!(out, "        // {description}");
761
762    for line in &setup_lines {
763        let _ = writeln!(out, "        {line}");
764    }
765
766    // Emit client construction if a client_factory is configured.
767    if let Some(setup) = &call_setup {
768        let _ = writeln!(out, "        {setup}");
769    }
770
771    if expects_error {
772        if is_async {
773            // XCTAssertThrowsError is a synchronous macro; for async-throwing
774            // functions use a do/catch with explicit XCTFail to enforce that
775            // the throw actually happens. `await XCTAssertThrowsError(...)` is
776            // not valid Swift — it evaluates `await` against a non-async expr.
777            let _ = writeln!(out, "        do {{");
778            let _ = writeln!(out, "            _ = {call_expr}");
779            let _ = writeln!(out, "            XCTFail(\"expected to throw\")");
780            let _ = writeln!(out, "        }} catch {{");
781            let _ = writeln!(out, "            // success");
782            let _ = writeln!(out, "        }}");
783        } else {
784            let _ = writeln!(out, "        XCTAssertThrowsError({call_expr})");
785        }
786        let _ = writeln!(out, "    }}");
787        return;
788    }
789
790    let _ = writeln!(out, "        let {result_var} = {call_expr}");
791
792    // Emit the collect snippet for streaming fixtures (drains the async sequence into
793    // a local `chunks: [ChatCompletionChunk]` array used by streaming-virtual assertions).
794    if !collect_snippet.is_empty() {
795        for line in collect_snippet.lines() {
796            let _ = writeln!(out, "        {line}");
797        }
798    }
799
800    for assertion in &fixture.assertions {
801        render_assertion(
802            out,
803            assertion,
804            result_var,
805            field_resolver,
806            result_is_simple,
807            result_is_array,
808            &effective_enum_fields,
809        );
810    }
811
812    let _ = writeln!(out, "    }}");
813}
814
815/// Build setup lines and the argument list for the function call.
816///
817/// Swift-bridge wrappers require strongly-typed values that don't have implicit
818/// Swift literal conversions:
819///
820/// - `bytes` args become `RustVec<UInt8>` — fixture supplies a relative file path
821///   string which is read at test time and pushed into a `RustVec<UInt8>` setup
822///   variable. A literal byte array is base64-decoded or UTF-8 encoded inline.
823/// - `json_object` args become opaque `ExtractionConfig` (or sibling) instances —
824///   a JSON string is decoded via `extractionConfigFromJson(...)` in a setup line.
825/// - Optional args missing from the fixture must still appear at the call site
826///   as `nil` whenever a later positional arg is present, otherwise Swift slots
827///   subsequent values into the wrong parameter.
828fn build_args_and_setup(
829    input: &serde_json::Value,
830    args: &[crate::config::ArgMapping],
831    fixture_id: &str,
832    has_host_root_route: bool,
833    function_name: &str,
834    options_via: Option<&str>,
835    options_type: Option<&str>,
836) -> (Vec<String>, String) {
837    if args.is_empty() {
838        return (Vec::new(), String::new());
839    }
840
841    let mut setup_lines: Vec<String> = Vec::new();
842    let mut parts: Vec<String> = Vec::new();
843
844    // Pre-compute, for each arg index, whether any later arg has a fixture-provided
845    // value (or is required and will emit a default). When an optional arg is empty
846    // but a later arg WILL emit, we must keep the slot with `nil` so positional
847    // alignment is preserved.
848    let later_emits: Vec<bool> = (0..args.len())
849        .map(|i| {
850            args.iter().skip(i + 1).any(|a| {
851                let f = a.field.strip_prefix("input.").unwrap_or(&a.field);
852                let v = input.get(f);
853                let has_value = matches!(v, Some(x) if !x.is_null());
854                has_value || !a.optional || (a.arg_type == "json_object" && a.name == "config")
855            })
856        })
857        .collect();
858
859    for (idx, arg) in args.iter().enumerate() {
860        if arg.arg_type == "mock_url" {
861            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_ascii_uppercase().replace('-', "_"));
862            let url_expr = if has_host_root_route {
863                format!(
864                    "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\")"
865                )
866            } else {
867                format!("ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"")
868            };
869            setup_lines.push(format!("let {} = {url_expr}", arg.name));
870            parts.push(arg.name.clone());
871            continue;
872        }
873
874        if arg.arg_type == "handle" {
875            let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
876            setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
877            parts.push(var_name);
878            continue;
879        }
880
881        // bytes args: fixture stores a fixture-relative path string. Generate
882        // setup that reads it into a Data and pushes each byte into a
883        // RustVec<UInt8>. Literal byte arrays inline the bytes; missing values
884        // produce an empty vec (or `nil` when optional).
885        if arg.arg_type == "bytes" {
886            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
887            let val = input.get(field);
888            match val {
889                None | Some(serde_json::Value::Null) if arg.optional => {
890                    if later_emits[idx] {
891                        parts.push("nil".to_string());
892                    }
893                }
894                None | Some(serde_json::Value::Null) => {
895                    let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
896                    setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
897                    parts.push(var_name);
898                }
899                Some(serde_json::Value::String(s)) => {
900                    let escaped = escape_swift(s);
901                    let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
902                    let data_var = format!("{}Data", arg.name.to_lower_camel_case());
903                    setup_lines.push(format!(
904                        "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
905                    ));
906                    setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
907                    setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
908                    parts.push(var_name);
909                }
910                Some(serde_json::Value::Array(arr)) => {
911                    let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
912                    setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
913                    for v in arr {
914                        if let Some(n) = v.as_u64() {
915                            setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
916                        }
917                    }
918                    parts.push(var_name);
919                }
920                Some(other) => {
921                    // Fallback: encode the JSON serialisation as UTF-8 bytes.
922                    let json_str = serde_json::to_string(other).unwrap_or_default();
923                    let escaped = escape_swift(&json_str);
924                    let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
925                    setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
926                    setup_lines.push(format!(
927                        "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
928                    ));
929                    parts.push(var_name);
930                }
931            }
932            continue;
933        }
934
935        // json_object "config" args: the swift-bridge wrapper requires an opaque
936        // `ExtractionConfig` (or sibling) instance, not a JSON string. Use the
937        // generated `extractionConfigFromJson(_:)` helper from RustBridge.
938        // Batch functions (batchExtract*) hardcode config internally — skip it.
939        let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
940        let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
941        if is_config_arg && !is_batch_fn {
942            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
943            let val = input.get(field);
944            let json_str = match val {
945                None | Some(serde_json::Value::Null) => "{}".to_string(),
946                Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
947            };
948            let escaped = escape_swift(&json_str);
949            let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
950            setup_lines.push(format!("let {var_name} = try extractionConfigFromJson(\"{escaped}\")"));
951            parts.push(var_name);
952            continue;
953        }
954
955        // json_object non-config args with options_via = "from_json":
956        // Use the generated `{typeCamelCase}FromJson(_:)` helper so the fixture JSON is
957        // deserialised into the opaque swift-bridge type rather than passed as a raw string.
958        // When arg.field == "input", the entire fixture input IS the request object.
959        if arg.arg_type == "json_object" && options_via == Some("from_json") {
960            if let Some(type_name) = options_type {
961                let resolved_val = super::resolve_field(input, &arg.field);
962                let json_str = match resolved_val {
963                    serde_json::Value::Null => "{}".to_string(),
964                    v => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
965                };
966                let escaped = escape_swift(&json_str);
967                let var_name = format!("_{}", arg.name.to_lower_camel_case());
968                let from_json_fn = format!("{}FromJson", type_name.to_lower_camel_case());
969                setup_lines.push(format!("let {var_name} = try {from_json_fn}(\"{escaped}\")"));
970                parts.push(var_name);
971                continue;
972            }
973        }
974
975        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
976        let val = input.get(field);
977        match val {
978            None | Some(serde_json::Value::Null) if arg.optional => {
979                // Optional arg with no fixture value: keep the slot with `nil`
980                // when a later arg will emit, so positional alignment matches
981                // the swift-bridge wrapper signature.
982                if later_emits[idx] {
983                    parts.push("nil".to_string());
984                }
985            }
986            None | Some(serde_json::Value::Null) => {
987                let default_val = match arg.arg_type.as_str() {
988                    "string" => "\"\"".to_string(),
989                    "int" | "integer" => "0".to_string(),
990                    "float" | "number" => "0.0".to_string(),
991                    "bool" | "boolean" => "false".to_string(),
992                    _ => "nil".to_string(),
993                };
994                parts.push(default_val);
995            }
996            Some(v) => {
997                parts.push(json_to_swift(v));
998            }
999        }
1000    }
1001
1002    (setup_lines, parts.join(", "))
1003}
1004
1005fn render_assertion(
1006    out: &mut String,
1007    assertion: &Assertion,
1008    result_var: &str,
1009    field_resolver: &FieldResolver,
1010    result_is_simple: bool,
1011    result_is_array: bool,
1012    enum_fields: &HashSet<String>,
1013) {
1014    // Streaming virtual fields resolve against the `chunks` collected-array variable.
1015    // Intercept before is_valid_for_result so they are never skipped.
1016    if let Some(f) = &assertion.field {
1017        if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1018            if let Some(expr) =
1019                crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "swift", "chunks")
1020            {
1021                let line = match assertion.assertion_type.as_str() {
1022                    "count_min" => {
1023                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1024                            format!("        XCTAssertGreaterThanOrEqual(chunks.count, {n})\n")
1025                        } else {
1026                            String::new()
1027                        }
1028                    }
1029                    "count_equals" => {
1030                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1031                            format!("        XCTAssertEqual(chunks.count, {n})\n")
1032                        } else {
1033                            String::new()
1034                        }
1035                    }
1036                    "equals" => {
1037                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1038                            let escaped = escape_swift(s);
1039                            format!("        XCTAssertEqual({expr}, \"{escaped}\")\n")
1040                        } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1041                            format!("        XCTAssertEqual({expr}, {b})\n")
1042                        } else {
1043                            String::new()
1044                        }
1045                    }
1046                    "not_empty" => {
1047                        format!("        XCTAssertFalse({expr}.isEmpty, \"expected non-empty\")\n")
1048                    }
1049                    "is_empty" => {
1050                        format!("        XCTAssertTrue({expr}.isEmpty, \"expected empty\")\n")
1051                    }
1052                    "is_true" => {
1053                        format!("        XCTAssertTrue({expr})\n")
1054                    }
1055                    "is_false" => {
1056                        format!("        XCTAssertFalse({expr})\n")
1057                    }
1058                    "greater_than" => {
1059                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1060                            format!("        XCTAssertGreaterThan(chunks.count, {n})\n")
1061                        } else {
1062                            String::new()
1063                        }
1064                    }
1065                    "contains" => {
1066                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1067                            let escaped = escape_swift(s);
1068                            format!(
1069                                "        XCTAssertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1070                            )
1071                        } else {
1072                            String::new()
1073                        }
1074                    }
1075                    _ => format!(
1076                        "        // streaming field '{f}': assertion type '{}' not rendered\n",
1077                        assertion.assertion_type
1078                    ),
1079                };
1080                if !line.is_empty() {
1081                    out.push_str(&line);
1082                }
1083            }
1084            return;
1085        }
1086    }
1087
1088    // Skip assertions on fields that don't exist on the result type.
1089    if let Some(f) = &assertion.field {
1090        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1091            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
1092            return;
1093        }
1094    }
1095
1096    // Skip assertions that traverse a tagged-union variant boundary.
1097    // In Swift, FormatMetadata and similar enum-backed opaque types are exposed as
1098    // plain classes by swift-bridge — variant accessor methods (e.g., `.excel()`)
1099    // are not generated, so such assertions cannot be expressed.
1100    if let Some(f) = &assertion.field {
1101        if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1102            let _ = writeln!(
1103                out,
1104                "        // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
1105            );
1106            return;
1107        }
1108    }
1109
1110    // Determine if this field is an enum type.
1111    let field_is_enum = assertion
1112        .field
1113        .as_deref()
1114        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1115
1116    let field_is_optional = assertion.field.as_deref().is_some_and(|f| {
1117        !f.is_empty() && (field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f)))
1118    });
1119    let field_is_array = assertion.field.as_deref().is_some_and(|f| {
1120        !f.is_empty() && (field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f)))
1121    });
1122
1123    let field_expr = if result_is_simple {
1124        result_var.to_string()
1125    } else {
1126        match &assertion.field {
1127            Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
1128            _ => result_var.to_string(),
1129        }
1130    };
1131
1132    // In Swift, optional chaining with `?.` makes the result optional even if the
1133    // called method's return type isn't marked optional. For example:
1134    // `result.markdown()?.content()` returns `Optional<RustString>` because
1135    // `markdown()` is optional and the `?.` operator wraps the result.
1136    // Detect this by checking if the accessor contains `?.`.
1137    let accessor_is_optional = field_expr.contains("?.");
1138
1139    // For enum fields, need to handle the string representation differently in Swift.
1140    // Swift enums don't have `.rawValue` unless they're explicitly RawRepresentable.
1141    // Check if this is an enum type and handle accordingly.
1142    // For optional fields (Optional<RustString>), use optional chaining before toString().
1143    // For other fields: swift-bridge returns all Rust `String` fields as `RustString`.
1144    // We add .toString() here so string assertions (contains, hasPrefix, etc.) work.
1145    // Non-string opaque fields (DocumentStructure, etc.) should not appear in string
1146    // assertions — the fixture schema controls which assertions apply to which fields.
1147    let string_expr = if field_is_enum {
1148        // swift-bridge exposes opaque enum types with a `toString()` method that returns
1149        // `RustString`. The second `.toString()` converts that `RustString` to a Swift String.
1150        format!("{field_expr}.toString().toString()")
1151    } else if field_is_optional {
1152        // Leaf field itself is Optional<RustString> — need ?.toString() to unwrap.
1153        format!("({field_expr}?.toString() ?? \"\")")
1154    } else if accessor_is_optional {
1155        // Ancestor optional chain propagates; leaf is non-optional RustString within chain.
1156        // Use .toString() directly — the whole expr is Optional<String> due to propagation.
1157        format!("({field_expr}.toString() ?? \"\")")
1158    } else {
1159        format!("{field_expr}.toString()")
1160    };
1161
1162    match assertion.assertion_type.as_str() {
1163        "equals" => {
1164            if let Some(expected) = &assertion.value {
1165                let swift_val = json_to_swift(expected);
1166                if expected.is_string() {
1167                    if field_is_enum {
1168                        // Enum fields: `toString()` on the opaque swift-bridge class returns a
1169                        // `RustString`; the second `.toString()` converts it to a Swift String.
1170                        // `string_expr` already incorporates this double call.
1171                        let trim_expr = format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespaces)");
1172                        let _ = writeln!(out, "        XCTAssertEqual({trim_expr}, {swift_val})");
1173                    } else {
1174                        // For optional strings (String?), use ?? to coalesce before trimming.
1175                        // `.toString()` converts RustString → Swift String before calling
1176                        // `.trimmingCharacters`, which requires a concrete String type.
1177                        // string_expr already incorporates field_is_optional via ?.toString() ?? "".
1178                        let trim_expr = format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespaces)");
1179                        let _ = writeln!(out, "        XCTAssertEqual({trim_expr}, {swift_val})");
1180                    }
1181                } else {
1182                    let _ = writeln!(out, "        XCTAssertEqual({field_expr}, {swift_val})");
1183                }
1184            }
1185        }
1186        "contains" => {
1187            if let Some(expected) = &assertion.value {
1188                let swift_val = json_to_swift(expected);
1189                // When the root result IS the array (result_is_simple + result_is_array) and
1190                // there is no field path, check array membership via map+contains.
1191                let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
1192                if result_is_simple && result_is_array && no_field {
1193                    // RustVec<RustString> iteration yields RustStringRef (no `toString()`);
1194                    // use `.as_str().toString()` to convert each element to a Swift String.
1195                    let _ = writeln!(
1196                        out,
1197                        "        XCTAssertTrue({result_var}.map {{ $0.as_str().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1198                    );
1199                } else {
1200                    // []. traversal: field like "links[].url" → contains(where:) closure.
1201                    let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1202                        if let Some(dot) = f.find("[].") {
1203                            let array_part = &f[..dot];
1204                            let elem_part = &f[dot + 3..];
1205                            let line = swift_traversal_contains_assert(
1206                                array_part,
1207                                elem_part,
1208                                f,
1209                                &swift_val,
1210                                result_var,
1211                                false,
1212                                &format!("expected to contain: \\({swift_val})"),
1213                                enum_fields,
1214                                field_resolver,
1215                            );
1216                            let _ = writeln!(out, "{line}");
1217                            true
1218                        } else {
1219                            false
1220                        }
1221                    } else {
1222                        false
1223                    };
1224                    if !traversal_handled {
1225                        // For array fields (RustVec<RustString>), check membership via map+contains.
1226                        let field_is_array = assertion
1227                            .field
1228                            .as_deref()
1229                            .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1230                        if field_is_array {
1231                            let contains_expr =
1232                                swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1233                            let _ = writeln!(
1234                                out,
1235                                "        XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1236                            );
1237                        } else if field_is_enum {
1238                            // Enum fields: use `toString().toString()` (via string_expr) to get the
1239                            // serde variant name as a Swift String, then check substring containment.
1240                            let _ = writeln!(
1241                                out,
1242                                "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1243                            );
1244                        } else {
1245                            let _ = writeln!(
1246                                out,
1247                                "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1248                            );
1249                        }
1250                    }
1251                }
1252            }
1253        }
1254        "contains_all" => {
1255            if let Some(values) = &assertion.values {
1256                // []. traversal: field like "links[].link_type" → contains(where:) per value.
1257                if let Some(f) = assertion.field.as_deref() {
1258                    if let Some(dot) = f.find("[].") {
1259                        let array_part = &f[..dot];
1260                        let elem_part = &f[dot + 3..];
1261                        for val in values {
1262                            let swift_val = json_to_swift(val);
1263                            let line = swift_traversal_contains_assert(
1264                                array_part,
1265                                elem_part,
1266                                f,
1267                                &swift_val,
1268                                result_var,
1269                                false,
1270                                &format!("expected to contain: \\({swift_val})"),
1271                                enum_fields,
1272                                field_resolver,
1273                            );
1274                            let _ = writeln!(out, "{line}");
1275                        }
1276                        // handled — skip remaining branches
1277                    } else {
1278                        // For array fields (RustVec<RustString>), check membership via map+contains.
1279                        let field_is_array = field_resolver.is_array(field_resolver.resolve(f));
1280                        if field_is_array {
1281                            let contains_expr =
1282                                swift_array_contains_expr(assertion.field.as_deref(), result_var, field_resolver);
1283                            for val in values {
1284                                let swift_val = json_to_swift(val);
1285                                let _ = writeln!(
1286                                    out,
1287                                    "        XCTAssertTrue(({contains_expr} ?? []).contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1288                                );
1289                            }
1290                        } else if field_is_enum {
1291                            // Enum fields: use `toString().toString()` (via string_expr) to get the
1292                            // serde variant name as a Swift String, then check substring containment.
1293                            for val in values {
1294                                let swift_val = json_to_swift(val);
1295                                let _ = writeln!(
1296                                    out,
1297                                    "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1298                                );
1299                            }
1300                        } else {
1301                            for val in values {
1302                                let swift_val = json_to_swift(val);
1303                                let _ = writeln!(
1304                                    out,
1305                                    "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1306                                );
1307                            }
1308                        }
1309                    }
1310                } else {
1311                    // No field — fall back to existing string_expr path.
1312                    for val in values {
1313                        let swift_val = json_to_swift(val);
1314                        let _ = writeln!(
1315                            out,
1316                            "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1317                        );
1318                    }
1319                }
1320            }
1321        }
1322        "not_contains" => {
1323            if let Some(expected) = &assertion.value {
1324                let swift_val = json_to_swift(expected);
1325                // []. traversal: "links[].url" → XCTAssertFalse(array.contains(where:))
1326                let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1327                    if let Some(dot) = f.find("[].") {
1328                        let array_part = &f[..dot];
1329                        let elem_part = &f[dot + 3..];
1330                        let line = swift_traversal_contains_assert(
1331                            array_part,
1332                            elem_part,
1333                            f,
1334                            &swift_val,
1335                            result_var,
1336                            true,
1337                            &format!("expected NOT to contain: \\({swift_val})"),
1338                            enum_fields,
1339                            field_resolver,
1340                        );
1341                        let _ = writeln!(out, "{line}");
1342                        true
1343                    } else {
1344                        false
1345                    }
1346                } else {
1347                    false
1348                };
1349                if !traversal_handled {
1350                    let _ = writeln!(
1351                        out,
1352                        "        XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
1353                    );
1354                }
1355            }
1356        }
1357        "not_empty" => {
1358            // For optional fields (Optional<T>), check that the value is non-nil.
1359            // For array fields (RustVec<T>), check .isEmpty on the vec directly.
1360            // For result_is_simple (e.g. Data, String), use .isEmpty directly on
1361            // the result — avoids calling .toString() on non-RustString types.
1362            // For string fields, convert to Swift String and check .isEmpty.
1363            // []. traversal: "links[].url" → contains(where: { !elem.isEmpty })
1364            let traversal_not_empty_handled = if let Some(f) = assertion.field.as_deref() {
1365                if let Some(dot) = f.find("[].") {
1366                    let array_part = &f[..dot];
1367                    let elem_part = &f[dot + 3..];
1368                    let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1369                    let resolved_full = field_resolver.resolve(f);
1370                    let resolved_elem_part = resolved_full
1371                        .find("[].")
1372                        .map(|d| &resolved_full[d + 3..])
1373                        .unwrap_or(elem_part);
1374                    let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1375                    let elem_is_enum = enum_fields.contains(f) || enum_fields.contains(resolved_full);
1376                    let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1377                        || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1378                    let elem_str = if elem_is_enum {
1379                        format!("{elem_accessor}.toString()")
1380                    } else if elem_is_optional {
1381                        format!("({elem_accessor}?.toString() ?? \"\")")
1382                    } else {
1383                        format!("{elem_accessor}.toString()")
1384                    };
1385                    let _ = writeln!(
1386                        out,
1387                        "        XCTAssertTrue({array_accessor}.contains(where: {{ !{elem_str}.isEmpty }}), \"expected non-empty value\")"
1388                    );
1389                    true
1390                } else {
1391                    false
1392                }
1393            } else {
1394                false
1395            };
1396            if !traversal_not_empty_handled {
1397                if field_is_optional {
1398                    let _ = writeln!(out, "        XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
1399                } else if field_is_array {
1400                    let _ = writeln!(
1401                        out,
1402                        "        XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
1403                    );
1404                } else if result_is_simple {
1405                    // result_is_simple: result is a primitive (Data, String, etc.) — use .isEmpty directly.
1406                    let _ = writeln!(
1407                        out,
1408                        "        XCTAssertFalse({result_var}.isEmpty, \"expected non-empty value\")"
1409                    );
1410                } else {
1411                    // string_expr has .toString() appended; .isEmpty works on Swift String.
1412                    let _ = writeln!(
1413                        out,
1414                        "        XCTAssertFalse({string_expr}.isEmpty, \"expected non-empty value\")"
1415                    );
1416                }
1417            }
1418        }
1419        "is_empty" => {
1420            if field_is_optional {
1421                let _ = writeln!(out, "        XCTAssertNil({field_expr}, \"expected nil value\")");
1422            } else if field_is_array {
1423                let _ = writeln!(
1424                    out,
1425                    "        XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
1426                );
1427            } else {
1428                let _ = writeln!(
1429                    out,
1430                    "        XCTAssertTrue({string_expr}.isEmpty, \"expected empty value\")"
1431                );
1432            }
1433        }
1434        "contains_any" => {
1435            if let Some(values) = &assertion.values {
1436                let checks: Vec<String> = values
1437                    .iter()
1438                    .map(|v| {
1439                        let swift_val = json_to_swift(v);
1440                        format!("{string_expr}.contains({swift_val})")
1441                    })
1442                    .collect();
1443                let joined = checks.join(" || ");
1444                let _ = writeln!(
1445                    out,
1446                    "        XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
1447                );
1448            }
1449        }
1450        "greater_than" => {
1451            if let Some(val) = &assertion.value {
1452                let swift_val = json_to_swift(val);
1453                // For optional numeric fields (or when the accessor chain is optional),
1454                // coalesce to 0 before comparing so the expression is non-optional.
1455                let field_is_optional = accessor_is_optional
1456                    || assertion.field.as_deref().is_some_and(|f| {
1457                        field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1458                    });
1459                let compare_expr = if field_is_optional {
1460                    format!("({field_expr} ?? 0)")
1461                } else {
1462                    field_expr.clone()
1463                };
1464                let _ = writeln!(out, "        XCTAssertGreaterThan({compare_expr}, {swift_val})");
1465            }
1466        }
1467        "less_than" => {
1468            if let Some(val) = &assertion.value {
1469                let swift_val = json_to_swift(val);
1470                let field_is_optional = accessor_is_optional
1471                    || assertion.field.as_deref().is_some_and(|f| {
1472                        field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1473                    });
1474                let compare_expr = if field_is_optional {
1475                    format!("({field_expr} ?? 0)")
1476                } else {
1477                    field_expr.clone()
1478                };
1479                let _ = writeln!(out, "        XCTAssertLessThan({compare_expr}, {swift_val})");
1480            }
1481        }
1482        "greater_than_or_equal" => {
1483            if let Some(val) = &assertion.value {
1484                let swift_val = json_to_swift(val);
1485                // For optional numeric fields (or when the accessor chain is optional),
1486                // coalesce to 0 before comparing so the expression is non-optional.
1487                let field_is_optional = accessor_is_optional
1488                    || assertion.field.as_deref().is_some_and(|f| {
1489                        field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1490                    });
1491                let compare_expr = if field_is_optional {
1492                    format!("({field_expr} ?? 0)")
1493                } else {
1494                    field_expr.clone()
1495                };
1496                let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({compare_expr}, {swift_val})");
1497            }
1498        }
1499        "less_than_or_equal" => {
1500            if let Some(val) = &assertion.value {
1501                let swift_val = json_to_swift(val);
1502                let field_is_optional = accessor_is_optional
1503                    || assertion.field.as_deref().is_some_and(|f| {
1504                        field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1505                    });
1506                let compare_expr = if field_is_optional {
1507                    format!("({field_expr} ?? 0)")
1508                } else {
1509                    field_expr.clone()
1510                };
1511                let _ = writeln!(out, "        XCTAssertLessThanOrEqual({compare_expr}, {swift_val})");
1512            }
1513        }
1514        "starts_with" => {
1515            if let Some(expected) = &assertion.value {
1516                let swift_val = json_to_swift(expected);
1517                let _ = writeln!(
1518                    out,
1519                    "        XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1520                );
1521            }
1522        }
1523        "ends_with" => {
1524            if let Some(expected) = &assertion.value {
1525                let swift_val = json_to_swift(expected);
1526                let _ = writeln!(
1527                    out,
1528                    "        XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1529                );
1530            }
1531        }
1532        "min_length" => {
1533            if let Some(val) = &assertion.value {
1534                if let Some(n) = val.as_u64() {
1535                    // Use string_expr.count: for RustString fields string_expr already has
1536                    // .toString() appended, giving a Swift String whose .count is character count.
1537                    let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1538                }
1539            }
1540        }
1541        "max_length" => {
1542            if let Some(val) = &assertion.value {
1543                if let Some(n) = val.as_u64() {
1544                    let _ = writeln!(out, "        XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1545                }
1546            }
1547        }
1548        "count_min" => {
1549            if let Some(val) = &assertion.value {
1550                if let Some(n) = val.as_u64() {
1551                    // For fields nested inside an optional parent (e.g. document.nodes where
1552                    // document is Optional), the accessor generates `result.document().nodes()`
1553                    // which doesn't compile in Swift without optional chaining.
1554                    let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1555                    let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({count_expr}, {n})");
1556                }
1557            }
1558        }
1559        "count_equals" => {
1560            if let Some(val) = &assertion.value {
1561                if let Some(n) = val.as_u64() {
1562                    let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
1563                    let _ = writeln!(out, "        XCTAssertEqual({count_expr}, {n})");
1564                }
1565            }
1566        }
1567        "is_true" => {
1568            let _ = writeln!(out, "        XCTAssertTrue({field_expr})");
1569        }
1570        "is_false" => {
1571            let _ = writeln!(out, "        XCTAssertFalse({field_expr})");
1572        }
1573        "matches_regex" => {
1574            if let Some(expected) = &assertion.value {
1575                let swift_val = json_to_swift(expected);
1576                let _ = writeln!(
1577                    out,
1578                    "        XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
1579                );
1580            }
1581        }
1582        "not_error" => {
1583            // Already handled by the call succeeding without exception.
1584        }
1585        "error" => {
1586            // Handled at the test method level.
1587        }
1588        "method_result" => {
1589            let _ = writeln!(out, "        // method_result assertions not yet implemented for Swift");
1590        }
1591        other => {
1592            panic!("Swift e2e generator: unsupported assertion type: {other}");
1593        }
1594    }
1595}
1596
1597/// Build a Swift accessor path for the given fixture field, inserting `()` on
1598/// every segment and `?` after every optional non-leaf segment.
1599///
1600/// This is the core helper for count/contains helpers that need to reconstruct
1601/// the path with correct optional chaining from the raw fixture field name.
1602///
1603/// Returns `(accessor_expr, has_optional)` where `has_optional` is true when
1604/// at least one `?.` was inserted.
1605fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
1606    let resolved = field_resolver.resolve(field);
1607    let parts: Vec<&str> = resolved.split('.').collect();
1608
1609    // Build a set of optional prefix paths for O(1) lookup during the walk.
1610    // We track path_so_far incrementally.
1611    let mut out = result_var.to_string();
1612    let mut has_optional = false;
1613    let mut path_so_far = String::new();
1614    let total = parts.len();
1615    for (i, part) in parts.iter().enumerate() {
1616        let is_leaf = i == total - 1;
1617        // Handle array index subscripts within a segment, e.g. `data[0]`.
1618        // `data[0]` must become `.data()[0]` not `.data[0]()`.
1619        // Split at the first `[` if present.
1620        let (field_name, subscript): (&str, Option<&str>) = if let Some(bracket_pos) = part.find('[') {
1621            (&part[..bracket_pos], Some(&part[bracket_pos..]))
1622        } else {
1623            (part, None)
1624        };
1625
1626        if !path_so_far.is_empty() {
1627            path_so_far.push('.');
1628        }
1629        path_so_far.push_str(part);
1630        out.push('.');
1631        out.push_str(field_name);
1632        out.push_str("()");
1633        if let Some(sub) = subscript {
1634            out.push_str(sub);
1635        }
1636        // Insert `?` after the last `()` (or subscript) for any non-leaf optional field
1637        // so the next member access becomes `?.`.
1638        if !is_leaf && field_resolver.is_optional(&path_so_far) {
1639            out.push('?');
1640            has_optional = true;
1641        }
1642    }
1643    (out, has_optional)
1644}
1645
1646/// Generate a `[String]?` expression for a `RustVec<RustString>` (or optional variant) field
1647/// so that `contains` membership checks work against plain Swift Strings.
1648///
1649/// The result is `Optional<[String]>` — callers should coalesce with `?? []`.
1650///
1651/// We use `?.map { $0.as_str().toString() }` because:
1652/// 1. Iterating a `RustVec<RustString>` yields `RustStringRef` (not `RustString`), which
1653///    only has `as_str()` but not `toString()` directly.
1654/// 2. The accessor may end with an `Optional<RustVec<RustString>>` (e.g. `sheet_names()` is
1655///    `Option<Vec<String>>` in Rust, which becomes `Optional<RustVec<RustString>>` in Swift).
1656/// 3. Optional chaining from parent `?.` already produces `Optional<RustVec<T>>`.
1657///
1658/// `?.map { $0.as_str().toString() }` converts each `RustStringRef` to a Swift `String`,
1659/// giving `[String]` wrapped in `Optional`. The `?? []` in callers coalesces nil to an empty
1660/// array.
1661/// Generate a `XCTAssert{True|False}(array.contains(where: { elem_str.contains(val) }), msg)` line
1662/// for field paths that traverse a collection with `[].` notation (e.g. `links[].url`).
1663///
1664/// `array_part` — left side of `[].` (e.g. `"links"`)
1665/// `element_part` — right side (e.g. `"url"` or `"link_type"`)
1666/// `full_field` — original assertion.field (used for enum lookup against the full path)
1667fn swift_traversal_contains_assert(
1668    array_part: &str,
1669    element_part: &str,
1670    full_field: &str,
1671    val_expr: &str,
1672    result_var: &str,
1673    negate: bool,
1674    msg: &str,
1675    enum_fields: &std::collections::HashSet<String>,
1676    field_resolver: &FieldResolver,
1677) -> String {
1678    let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1679    let resolved_full = field_resolver.resolve(full_field);
1680    let resolved_elem_part = resolved_full
1681        .find("[].")
1682        .map(|d| &resolved_full[d + 3..])
1683        .unwrap_or(element_part);
1684    let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1685    let elem_is_enum = enum_fields.contains(full_field) || enum_fields.contains(resolved_full);
1686    let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1687        || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1688    let elem_str = if elem_is_enum {
1689        format!("{elem_accessor}.toString()")
1690    } else if elem_is_optional {
1691        format!("({elem_accessor}?.toString() ?? \"\")")
1692    } else {
1693        format!("{elem_accessor}.toString()")
1694    };
1695    let assert_fn = if negate { "XCTAssertFalse" } else { "XCTAssertTrue" };
1696    format!("        {assert_fn}({array_accessor}.contains(where: {{ {elem_str}.contains({val_expr}) }}), \"{msg}\")")
1697}
1698
1699fn swift_array_contains_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1700    let Some(f) = field else {
1701        return format!("{result_var}.map {{ $0.as_str().toString() }}");
1702    };
1703    let (accessor, _has_optional) = swift_build_accessor(f, result_var, field_resolver);
1704    // Always use `?.map` — the array field (sheet_names, etc.) may itself return
1705    // Optional<RustVec<T>> even if not listed in fields_optional.
1706    format!("{accessor}?.map {{ $0.as_str().toString() }}")
1707}
1708
1709/// Generate a `.count` expression for an array field that may be nested inside optional parents.
1710///
1711/// Swift-bridge exposes all Rust fields as methods with `()`. When ancestor segments are
1712/// optional, we use `?.` chaining. The final count is coalesced with `?? 0` when there
1713/// are optional ancestors so the XCTAssert macro receives a non-optional `Int`.
1714///
1715/// Also check if the field itself (the leaf) is optional, which happens when the field
1716/// returns Optional<RustVec<T>> (e.g., `links()` may return Optional).
1717fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
1718    let Some(f) = field else {
1719        return format!("{result_var}.count");
1720    };
1721    let (accessor, mut has_optional) = swift_build_accessor(f, result_var, field_resolver);
1722    // Also check if the leaf field itself is optional.
1723    if field_resolver.is_optional(f) {
1724        has_optional = true;
1725    }
1726    if has_optional {
1727        // In Swift, accessing .count on an optional with ?. returns Optional<Int>,
1728        // so we coalesce with ?? 0 to get a concrete Int for XCTAssert.
1729        if accessor.contains("?.") {
1730            format!("{accessor}.count ?? 0")
1731        } else {
1732            // If no ?. but field is optional, the field_expr itself is Optional<RustVec<T>>
1733            // so we need ?. to call count.
1734            format!("({accessor}?.count ?? 0)")
1735        }
1736    } else {
1737        format!("{accessor}.count")
1738    }
1739}
1740
1741/// Normalise a path by resolving `..` components without hitting the filesystem.
1742///
1743/// This mirrors what `std::fs::canonicalize` does but works on paths that do
1744/// not yet exist on disk (generated-file paths).  Only `..` traversals are
1745/// collapsed; `.` components are dropped; nothing else is changed.
1746fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
1747    let mut components = std::path::PathBuf::new();
1748    for component in path.components() {
1749        match component {
1750            std::path::Component::ParentDir => {
1751                // Pop the last pushed component if there is one that isn't
1752                // already a `..` (avoids over-collapsing `../../foo`).
1753                if !components.as_os_str().is_empty() {
1754                    components.pop();
1755                } else {
1756                    components.push(component);
1757                }
1758            }
1759            std::path::Component::CurDir => {}
1760            other => components.push(other),
1761        }
1762    }
1763    components
1764}
1765
1766/// Convert a `serde_json::Value` to a Swift literal string.
1767fn json_to_swift(value: &serde_json::Value) -> String {
1768    match value {
1769        serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
1770        serde_json::Value::Bool(b) => b.to_string(),
1771        serde_json::Value::Number(n) => n.to_string(),
1772        serde_json::Value::Null => "nil".to_string(),
1773        serde_json::Value::Array(arr) => {
1774            let items: Vec<String> = arr.iter().map(json_to_swift).collect();
1775            format!("[{}]", items.join(", "))
1776        }
1777        serde_json::Value::Object(_) => {
1778            let json_str = serde_json::to_string(value).unwrap_or_default();
1779            format!("\"{}\"", escape_swift(&json_str))
1780        }
1781    }
1782}
1783
1784/// Escape a string for embedding in a Swift double-quoted string literal.
1785fn escape_swift(s: &str) -> String {
1786    escape_swift_str(s)
1787}