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