Skip to main content

alef_e2e/codegen/
swift.rs

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