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