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