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