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