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>`) 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    // RustBridge is needed for low-level types (RustVec<UInt8>, RustString) constructed
345    // in bytes/string argument setup. It is exposed as a product by the swift package
346    // for e2e test use.
347    let _ = writeln!(out, "import RustBridge");
348    let _ = writeln!(out);
349    let _ = writeln!(out, "/// E2e tests for category: {category}.");
350    let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
351
352    if needs_chdir {
353        // Chdir once at class setUp so all fixture file_path arguments resolve relative
354        // to the repository's test_documents directory.
355        //
356        // #filePath = <repo>/e2e/swift_e2e/Tests/<Module>E2ETests/<Class>.swift
357        // 5 deletingLastPathComponent() calls climb to the repo root before appending
358        // "test_documents". Mirrors the Ruby/Python conftest pattern that chdirs to
359        // test_documents.
360        let _ = writeln!(out, "    override class func setUp() {{");
361        let _ = writeln!(out, "        super.setUp()");
362        let _ = writeln!(out, "        let _testDocs = URL(fileURLWithPath: #filePath)");
363        let _ = writeln!(out, "            .deletingLastPathComponent() // <Module>Tests/");
364        let _ = writeln!(out, "            .deletingLastPathComponent() // Tests/");
365        let _ = writeln!(out, "            .deletingLastPathComponent() // swift/");
366        let _ = writeln!(out, "            .deletingLastPathComponent() // packages/");
367        let _ = writeln!(out, "            .deletingLastPathComponent() // <repo root>");
368        let _ = writeln!(
369            out,
370            "            .appendingPathComponent(\"{}\")",
371            e2e_config.test_documents_dir
372        );
373        let _ = writeln!(
374            out,
375            "        if FileManager.default.fileExists(atPath: _testDocs.path) {{"
376        );
377        let _ = writeln!(
378            out,
379            "            FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
380        );
381        let _ = writeln!(out, "        }}");
382        let _ = writeln!(out, "    }}");
383        let _ = writeln!(out);
384    }
385
386    for fixture in fixtures {
387        if fixture.is_http_test() {
388            render_http_test_method(&mut out, fixture);
389        } else {
390            render_test_method(
391                &mut out,
392                fixture,
393                e2e_config,
394                function_name,
395                result_var,
396                args,
397                result_is_simple,
398                client_factory,
399                swift_first_class_map,
400                module_name,
401            );
402        }
403        let _ = writeln!(out);
404    }
405
406    let _ = writeln!(out, "}}");
407    out
408}
409
410// ---------------------------------------------------------------------------
411// HTTP test rendering — TestClientRenderer impl + thin driver wrapper
412// ---------------------------------------------------------------------------
413
414/// Renderer that emits XCTest `func test...() throws` methods using `URLSession`
415/// against the mock server (`ProcessInfo.processInfo.environment["MOCK_SERVER_URL"]`).
416struct SwiftTestClientRenderer;
417
418impl client::TestClientRenderer for SwiftTestClientRenderer {
419    fn language_name(&self) -> &'static str {
420        "swift"
421    }
422
423    fn sanitize_test_name(&self, id: &str) -> String {
424        // Swift test methods are `func testFoo()` — upper-camel-case after "test".
425        sanitize_ident(id).to_upper_camel_case()
426    }
427
428    /// Emit `func test{FnName}() throws {` (or a skip stub when the fixture is skipped).
429    ///
430    /// XCTest has no first-class skip annotation prior to Swift Testing (`@Test`).
431    /// For skipped fixtures we emit `try XCTSkipIf(true, reason)` inside the
432    /// function body so XCTest records them as skipped rather than omitting them.
433    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
434        let _ = writeln!(out, "    /// {description}");
435        let _ = writeln!(out, "    func test{fn_name}() throws {{");
436        if let Some(reason) = skip_reason {
437            let escaped = escape_swift(reason);
438            let _ = writeln!(out, "        try XCTSkipIf(true, \"{escaped}\")");
439        }
440    }
441
442    fn render_test_close(&self, out: &mut String) {
443        let _ = writeln!(out, "    }}");
444    }
445
446    /// Emit a synchronous `URLSession` round-trip to the mock server.
447    ///
448    /// `ProcessInfo.processInfo.environment["MOCK_SERVER_URL"]!` provides the base
449    /// URL; the fixture path is appended directly.  The call uses a semaphore so the
450    /// generated test body stays synchronous (compatible with `throws` functions —
451    /// no `async` XCTest support needed).
452    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
453        let method = ctx.method.to_uppercase();
454        let fixture_path = escape_swift(ctx.path);
455
456        let _ = writeln!(
457            out,
458            "        let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
459        );
460        let _ = writeln!(
461            out,
462            "        var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
463        );
464        let _ = writeln!(out, "        _req.httpMethod = \"{method}\"");
465
466        // Headers
467        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
468        header_pairs.sort_by_key(|(k, _)| k.as_str());
469        for (k, v) in &header_pairs {
470            let expanded_v = expand_fixture_templates(v);
471            let ek = escape_swift(k);
472            let ev = escape_swift(&expanded_v);
473            let _ = writeln!(out, "        _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
474        }
475
476        // Body
477        if let Some(body) = ctx.body {
478            let json_str = serde_json::to_string(body).unwrap_or_default();
479            let escaped_body = escape_swift(&json_str);
480            let _ = writeln!(out, "        _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
481            let _ = writeln!(
482                out,
483                "        _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
484            );
485        }
486
487        let _ = writeln!(out, "        var {}: HTTPURLResponse?", ctx.response_var);
488        let _ = writeln!(out, "        var _responseData: Data?");
489        let _ = writeln!(out, "        let _sema = DispatchSemaphore(value: 0)");
490        let _ = writeln!(
491            out,
492            "        URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
493        );
494        let _ = writeln!(out, "            {} = resp as? HTTPURLResponse", ctx.response_var);
495        let _ = writeln!(out, "            _responseData = data");
496        let _ = writeln!(out, "            _sema.signal()");
497        let _ = writeln!(out, "        }}.resume()");
498        let _ = writeln!(out, "        _sema.wait()");
499        let _ = writeln!(out, "        let _resp = try XCTUnwrap({})", ctx.response_var);
500    }
501
502    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
503        let _ = writeln!(out, "        XCTAssertEqual(_resp.statusCode, {status})");
504    }
505
506    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
507        let lower_name = name.to_lowercase();
508        let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
509        match expected {
510            "<<present>>" => {
511                let _ = writeln!(out, "        XCTAssertNotNil({header_expr})");
512            }
513            "<<absent>>" => {
514                let _ = writeln!(out, "        XCTAssertNil({header_expr})");
515            }
516            "<<uuid>>" => {
517                let _ = writeln!(out, "        let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
518                let _ = writeln!(
519                    out,
520                    "        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))"
521                );
522            }
523            exact => {
524                let escaped = escape_swift(exact);
525                let _ = writeln!(out, "        XCTAssertEqual({header_expr}, \"{escaped}\")");
526            }
527        }
528    }
529
530    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
531        if let serde_json::Value::String(s) = expected {
532            let escaped = escape_swift(s);
533            let _ = writeln!(
534                out,
535                "        let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
536            );
537            let _ = writeln!(
538                out,
539                "        XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
540            );
541        } else {
542            let json_str = serde_json::to_string(expected).unwrap_or_default();
543            let escaped = escape_swift(&json_str);
544            let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
545            let _ = writeln!(
546                out,
547                "        let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
548            );
549            let _ = writeln!(
550                out,
551                "        let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
552            );
553            let _ = writeln!(
554                out,
555                "        XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
556            );
557        }
558    }
559
560    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
561        if let Some(obj) = expected.as_object() {
562            let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
563            let _ = writeln!(
564                out,
565                "        let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
566            );
567            for (key, val) in obj {
568                let escaped_key = escape_swift(key);
569                let swift_val = json_to_swift(val);
570                let _ = writeln!(
571                    out,
572                    "        XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
573                );
574            }
575        }
576    }
577
578    fn render_assert_validation_errors(
579        &self,
580        out: &mut String,
581        _response_var: &str,
582        errors: &[ValidationErrorExpectation],
583    ) {
584        let _ = writeln!(out, "        let _bodyData = try XCTUnwrap(_responseData)");
585        let _ = writeln!(
586            out,
587            "        let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
588        );
589        let _ = writeln!(
590            out,
591            "        let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
592        );
593        for ve in errors {
594            let escaped_msg = escape_swift(&ve.msg);
595            let _ = writeln!(
596                out,
597                "        XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
598            );
599        }
600    }
601}
602
603/// Render an XCTest method for an HTTP server fixture via the shared driver.
604///
605/// HTTP 101 (WebSocket upgrade) is emitted as a skip stub because `URLSession`
606/// cannot handle Upgrade responses.
607fn render_http_test_method(out: &mut String, fixture: &Fixture) {
608    let Some(http) = &fixture.http else {
609        return;
610    };
611
612    // HTTP 101 (WebSocket upgrade) — URLSession cannot handle upgrade responses.
613    if http.expected_response.status_code == 101 {
614        let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
615        let description = fixture.description.replace('"', "\\\"");
616        let _ = writeln!(out, "    /// {description}");
617        let _ = writeln!(out, "    func test{method_name}() throws {{");
618        let _ = writeln!(
619            out,
620            "        try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
621        );
622        let _ = writeln!(out, "    }}");
623        return;
624    }
625
626    client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
627}
628
629// ---------------------------------------------------------------------------
630// Function-call test rendering
631// ---------------------------------------------------------------------------
632
633#[allow(clippy::too_many_arguments)]
634fn render_test_method(
635    out: &mut String,
636    fixture: &Fixture,
637    e2e_config: &E2eConfig,
638    _function_name: &str,
639    _result_var: &str,
640    _args: &[crate::config::ArgMapping],
641    result_is_simple: bool,
642    global_client_factory: Option<&str>,
643    swift_first_class_map: &SwiftFirstClassMap,
644    module_name: &str,
645) {
646    // Resolve per-fixture call config.
647    let call_config = e2e_config.resolve_call_for_fixture(
648        fixture.call.as_deref(),
649        &fixture.id,
650        &fixture.resolved_category(),
651        &fixture.tags,
652        &fixture.input,
653    );
654    // Build per-call field resolver using the effective field sets for this call.
655    let call_field_resolver = FieldResolver::new_with_swift_first_class(
656        e2e_config.effective_fields(call_config),
657        e2e_config.effective_fields_optional(call_config),
658        e2e_config.effective_result_fields(call_config),
659        e2e_config.effective_fields_array(call_config),
660        e2e_config.effective_fields_method_calls(call_config),
661        &HashMap::new(),
662        swift_first_class_map.clone(),
663    );
664    let field_resolver = &call_field_resolver;
665    let enum_fields = e2e_config.effective_fields_enum(call_config);
666    let lang = "swift";
667    let call_overrides = call_config.overrides.get(lang);
668    let function_name = call_overrides
669        .and_then(|o| o.function.as_ref())
670        .cloned()
671        .unwrap_or_else(|| swift_ident(&call_config.function.to_lower_camel_case()));
672    // Per-call client_factory takes precedence over the global one.
673    let client_factory: Option<&str> = call_overrides
674        .and_then(|o| o.client_factory.as_deref())
675        .or(global_client_factory);
676    let result_var = &call_config.result_var;
677    let args = &call_config.args;
678    // Per-call flags: base call flag OR per-language override OR global flag.
679    // Also treat the call as simple when *any* language override marks it as bytes.
680    // Calls like `speech()` have `result_is_bytes = true` on C/C#/Java overrides but
681    // no explicit `result_is_simple` on the Swift override — yet the Swift binding
682    // returns `Data` directly (not a struct), so assertions must use `result.isEmpty`
683    // rather than `result.audio().toString().isEmpty`.
684    let result_is_bytes_any_lang =
685        call_config.result_is_bytes || call_config.overrides.values().any(|o| o.result_is_bytes);
686    let result_is_simple = call_config.result_is_simple
687        || call_overrides.is_some_and(|o| o.result_is_simple)
688        || result_is_simple
689        || result_is_bytes_any_lang;
690    let result_is_array = call_config.result_is_array;
691    // When the call returns `Option<T>` the Swift binding exposes the result as
692    // `Optional<…>` (e.g. `getEmbeddingPreset(...) -> EmbeddingPreset?`). Bare-result
693    // `is_empty`/`not_empty` assertions must use `XCTAssertNil` / `XCTAssertNotNil`
694    // rather than `.toString().isEmpty`, which is undefined on opaque optionals.
695    let result_is_option = call_config.result_is_option || call_overrides.is_some_and(|o| o.result_is_option);
696    let result_element_is_string =
697        call_config.result_element_is_string || call_overrides.is_some_and(|o| o.result_element_is_string);
698    // Per-language map of array-result-field → element accessor method (e.g.
699    // `structure → kind`). Empty map when no override is configured.
700    let result_field_accessor: &HashMap<String, String> = call_overrides
701        .map(|o| &o.result_field_accessor)
702        .unwrap_or_else(|| empty_field_accessor_map());
703
704    let method_name = fixture.id.to_upper_camel_case();
705    let description = &fixture.description;
706    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
707    let is_async = call_config.r#async;
708
709    // Streaming detection (call-level `streaming` opt-out is honored).
710    let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
711    let collect_snippet_opt = if is_streaming && !expects_error {
712        crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(lang, result_var, "chunks")
713    } else {
714        None
715    };
716    // When swift has streaming-virtual-field assertions but no collect snippet
717    // is available (the swift-bridge surface does not yet expose a typed
718    // `chatStream` async sequence we can drain into a typed
719    // `[ChatCompletionChunk]`), emit a skip stub rather than reference an
720    // undefined `chunks` local in the assertion expressions. This keeps the
721    // swift test target compiling while the binding catches up.
722    if is_streaming && !expects_error && collect_snippet_opt.is_none() {
723        if is_async {
724            let _ = writeln!(out, "    func test{method_name}() async throws {{");
725        } else {
726            let _ = writeln!(out, "    func test{method_name}() throws {{");
727        }
728        let _ = writeln!(out, "        // {description}");
729        let _ = writeln!(
730            out,
731            "        try XCTSkipIf(true, \"swift: streaming chunk collection is not yet supported via the swift-bridge surface (fixture: {})\")",
732            fixture.id
733        );
734        let _ = writeln!(out, "    }}");
735        return;
736    }
737    let collect_snippet = collect_snippet_opt.unwrap_or_default();
738    // The shared streaming snippet references the unqualified `ChatCompletionChunk`
739    // type, but Swift consumers import both `<Module>` (the alef-emitted first-class
740    // `public struct ChatCompletionChunk`) AND `RustBridge` (the swift-bridge
741    // generated `public class ChatCompletionChunk`). Without module qualification
742    // Swift fails the test target with "'ChatCompletionChunk' is ambiguous for
743    // type lookup". Qualify to the first-class type so `chunks` is `[<Module>.ChatCompletionChunk]`.
744    let collect_snippet = if collect_snippet.is_empty() {
745        collect_snippet
746    } else {
747        collect_snippet.replace("[ChatCompletionChunk]", &format!("[{module_name}.ChatCompletionChunk]"))
748    };
749
750    // Detect whether this call has any json_object args that cannot be constructed
751    // in Swift — swift-bridge opaque types do not provide a fromJson initialiser.
752    // When such args exist and no `options_via` is configured for swift, emit a
753    // skip stub so the test compiles but is recorded as skipped rather than
754    // generating invalid code that passes `nil` or a string literal where a
755    // strongly-typed request object is required.
756    //
757    // Args with a scalar `element_type` (e.g. `"String"`, `"i32"`, `"f64"`,
758    // `"bool"`) describe a `Vec<T>` parameter on the Rust side, which the Swift
759    // binding exposes as a native `[T]` array. These map cleanly onto Swift
760    // array literals and do not require options_via configuration.
761    let has_unresolvable_json_object_arg = {
762        let options_via = call_overrides.and_then(|o| o.options_via.as_deref());
763        options_via.is_none()
764            && args.iter().any(|a| {
765                a.arg_type == "json_object" && a.name != "config" && !is_scalar_element_type(a.element_type.as_deref())
766            })
767    };
768
769    if has_unresolvable_json_object_arg {
770        if is_async {
771            let _ = writeln!(out, "    func test{method_name}() async throws {{");
772        } else {
773            let _ = writeln!(out, "    func test{method_name}() throws {{");
774        }
775        let _ = writeln!(out, "        // {description}");
776        let _ = writeln!(
777            out,
778            "        try XCTSkipIf(true, \"swift: json_object request construction requires options_via configuration (fixture: {})\");",
779            fixture.id
780        );
781        let _ = writeln!(out, "    }}");
782        return;
783    }
784
785    // Visitor-driven fixtures: emit a class that conforms to `HtmlVisitorProtocol`
786    // and wrap it via `makeHtmlVisitorHandle(...)`. The handle is then threaded
787    // into the options via `conversionOptionsFromJsonWithVisitor(json, handle)`.
788    let mut visitor_setup_lines: Vec<String> = Vec::new();
789    let visitor_handle_expr: Option<String> = fixture
790        .visitor
791        .as_ref()
792        .map(|spec| super::swift_visitors::build_swift_visitor(&mut visitor_setup_lines, spec, &fixture.id));
793
794    // Resolve extra_args from per-call swift overrides (e.g. `nil` for optional
795    // query-param arguments on list_files/list_batches that have no fixture-level
796    // input field).
797    let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
798
799    // Merge per-call enum_fields keys into the effective enum set so that
800    // fields like "status" (BatchStatus, BatchObject) are treated as enum-typed
801    // even when they are not globally listed in fields_enum (they are context-
802    // dependent — BatchStatus on BatchObject but plain String on ResponseObject).
803    let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
804        let per_call = call_overrides.map(|o| &o.enum_fields);
805        if let Some(pc) = per_call {
806            if !pc.is_empty() {
807                let mut merged = enum_fields.clone();
808                merged.extend(pc.keys().cloned());
809                std::borrow::Cow::Owned(merged)
810            } else {
811                std::borrow::Cow::Borrowed(enum_fields)
812            }
813        } else {
814            std::borrow::Cow::Borrowed(enum_fields)
815        }
816    };
817
818    let options_via_str: Option<&str> = call_overrides.and_then(|o| o.options_via.as_deref());
819    let options_type_str: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
820    // Derive the Swift handle-config parsing function from the C override's
821    // `c_engine_factory` field. E.g. `"CrawlConfig"` → snake → `"crawl_config_from_json"`
822    // → camelCase → `"crawlConfigFromJson"`.
823    let handle_config_fn_owned: Option<String> = call_config
824        .overrides
825        .get("c")
826        .and_then(|c| c.c_engine_factory.as_deref())
827        .map(|ty| format!("{}_from_json", ty.to_snake_case()).to_lower_camel_case());
828    let unnamed_arg_indices: &[usize] = call_overrides.map(|o| &o.unnamed_arg_indices[..]).unwrap_or(&[]);
829    let (mut setup_lines, args_str) = build_args_and_setup(
830        &fixture.input,
831        args,
832        &fixture.id,
833        fixture.has_host_root_route(),
834        &function_name,
835        options_via_str,
836        options_type_str,
837        handle_config_fn_owned.as_deref(),
838        visitor_handle_expr.as_deref(),
839        client_factory.is_some(),
840        module_name,
841        unnamed_arg_indices,
842    );
843    // Prepend visitor class declarations (before any setup lines that reference the handle).
844    if !visitor_setup_lines.is_empty() {
845        visitor_setup_lines.extend(setup_lines);
846        setup_lines = visitor_setup_lines;
847    }
848
849    // Append extra_args to the argument list.
850    let args_str = if extra_args.is_empty() {
851        args_str
852    } else if args_str.is_empty() {
853        extra_args.join(", ")
854    } else {
855        format!("{args_str}, {}", extra_args.join(", "))
856    };
857
858    // When a client_factory is set, dispatch via a client instance:
859    //   let client = try <FactoryType>(apiKey: "test-key", baseUrl: <mock_url>)
860    //   try await client.<method>(args)
861    // Otherwise fall back to free-function call (Kreuzberg / non-client-factory libraries).
862    let has_mock = fixture.mock_response.is_some();
863    let (call_setup, call_expr) = if let Some(_factory) = client_factory {
864        let env_key = format!("MOCK_SERVER_{}", fixture.id.to_ascii_uppercase().replace('-', "_"));
865        let mock_url = if fixture.has_host_root_route() {
866            format!(
867                "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\")",
868                fixture.id
869            )
870        } else {
871            format!(
872                "ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\"",
873                fixture.id
874            )
875        };
876        let client_constructor = if has_mock {
877            format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
878        } else {
879            // Live API: check for api_key_var; if not present use mock URL anyway.
880            if let Some(env_var) = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()) {
881                format!(
882                    "let _apiKey = ProcessInfo.processInfo.environment[\"{env_var}\"]\n        \
883                     let _baseUrl: String? = _apiKey != nil ? nil : {mock_url}\n        \
884                     let _client = try DefaultClient(apiKey: _apiKey ?? \"test-key\", baseUrl: _baseUrl)"
885                )
886            } else {
887                format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
888            }
889        };
890        let expr = if is_async {
891            format!("try await _client.{function_name}({args_str})")
892        } else {
893            format!("try _client.{function_name}({args_str})")
894        };
895        (Some(client_constructor), expr)
896    } else {
897        // Free-function call (no client_factory).
898        // Qualify with module name to disambiguate between high-level and swift-bridge symbols.
899        let expr = if is_async {
900            format!("try await {module_name}.{function_name}({args_str})")
901        } else {
902            format!("try {module_name}.{function_name}({args_str})")
903        };
904        (None, expr)
905    };
906    // For backwards compatibility: qualified_function_name unused when client_factory is set.
907    let _ = function_name;
908
909    if is_async {
910        let _ = writeln!(out, "    func test{method_name}() async throws {{");
911    } else {
912        let _ = writeln!(out, "    func test{method_name}() throws {{");
913    }
914    let _ = writeln!(out, "        // {description}");
915
916    if expects_error {
917        // For error fixtures, setup may itself throw (e.g. config validation
918        // happens at engine construction). Wrap the whole pipeline — setup
919        // and the call — in a single do/catch so any throw counts as success.
920        if is_async {
921            // XCTAssertThrowsError is a synchronous macro; for async-throwing
922            // functions use a do/catch with explicit XCTFail to enforce that
923            // the throw actually happens. `await XCTAssertThrowsError(...)` is
924            // not valid Swift — it evaluates `await` against a non-async expr.
925            let _ = writeln!(out, "        do {{");
926            for line in &setup_lines {
927                let _ = writeln!(out, "            {line}");
928            }
929            if let Some(setup) = &call_setup {
930                let _ = writeln!(out, "            {setup}");
931            }
932            let _ = writeln!(out, "            _ = {call_expr}");
933            let _ = writeln!(out, "            XCTFail(\"expected to throw\")");
934            let _ = writeln!(out, "        }} catch {{");
935            let _ = writeln!(out, "            // success");
936            let _ = writeln!(out, "        }}");
937        } else {
938            // Synchronous: emit setup outside (it's expected to succeed) and
939            // wrap only the throwing call in XCTAssertThrowsError. If setup
940            // itself throws, that propagates as the test's own failure — but
941            // sync tests use `throws` so the test method itself rethrows,
942            // which XCTest still records as caught. Keep this simple: use a
943            // do/catch so setup-time throws also count as expected failures.
944            let _ = writeln!(out, "        do {{");
945            for line in &setup_lines {
946                let _ = writeln!(out, "            {line}");
947            }
948            if let Some(setup) = &call_setup {
949                let _ = writeln!(out, "            {setup}");
950            }
951            let _ = writeln!(out, "            _ = {call_expr}");
952            let _ = writeln!(out, "            XCTFail(\"expected to throw\")");
953            let _ = writeln!(out, "        }} catch {{");
954            let _ = writeln!(out, "            // success");
955            let _ = writeln!(out, "        }}");
956        }
957        let _ = writeln!(out, "    }}");
958        return;
959    }
960
961    for line in &setup_lines {
962        let _ = writeln!(out, "        {line}");
963    }
964
965    // Emit client construction if a client_factory is configured.
966    if let Some(setup) = &call_setup {
967        let _ = writeln!(out, "        {setup}");
968    }
969
970    let _ = writeln!(out, "        let {result_var} = {call_expr}");
971
972    // Emit the collect snippet for streaming fixtures (drains the async sequence into
973    // a local `chunks: [ChatCompletionChunk]` array used by streaming-virtual assertions).
974    if !collect_snippet.is_empty() {
975        for line in collect_snippet.lines() {
976            let _ = writeln!(out, "        {line}");
977        }
978    }
979
980    // Each fixture's call returns a different IR type. Override the resolver's
981    // Swift first-class-map `root_type` with the call's `result_type` (looked up
982    // across c/csharp/java/kotlin/go/php overrides — these are language-agnostic
983    // IR type names that any backend can use to anchor field-access dispatch).
984    let fixture_root_type: Option<String> = swift_call_result_type(call_config);
985    let fixture_resolver = field_resolver.with_swift_root_type(fixture_root_type);
986
987    for assertion in &fixture.assertions {
988        let mut assertion_out = String::new();
989        render_assertion(
990            &mut assertion_out,
991            assertion,
992            result_var,
993            &fixture_resolver,
994            result_is_simple,
995            result_is_array,
996            result_is_option,
997            result_element_is_string,
998            result_field_accessor,
999            &effective_enum_fields,
1000            is_streaming,
1001        );
1002        // Module-qualify swift-bridge-ambiguous DTO type names that appear in
1003        // streaming-virtual assertion expressions (e.g. `[StreamToolCall]`,
1004        // `[ToolCall]`). Both `<Module>` (first-class Codable struct) and
1005        // `RustBridge` (swift-bridge opaque class) export the same identifier,
1006        // so unqualified usage fails Swift compilation with "X is ambiguous for
1007        // type lookup". Mirrors the `[ChatCompletionChunk]` replacement in
1008        // `render_test_method`.
1009        for unqualified in ["StreamToolCall", "ToolCall"] {
1010            assertion_out =
1011                assertion_out.replace(&format!("[{unqualified}]"), &format!("[{module_name}.{unqualified}]"));
1012        }
1013        out.push_str(&assertion_out);
1014    }
1015
1016    let _ = writeln!(out, "    }}");
1017}
1018
1019#[allow(clippy::too_many_arguments)]
1020/// Build setup lines and the argument list for the function call.
1021///
1022/// Swift-bridge wrappers require strongly-typed values that don't have implicit
1023/// Swift literal conversions:
1024///
1025/// - `bytes` args become `RustVec<UInt8>` — fixture supplies a relative file path
1026///   string which is read at test time and pushed into a `RustVec<UInt8>` setup
1027///   variable. A literal byte array is base64-decoded or UTF-8 encoded inline.
1028/// - `json_object` args become opaque `ExtractionConfig` (or sibling) instances —
1029///   a JSON string is decoded via `extractionConfigFromJson(...)` in a setup line.
1030/// - Optional args missing from the fixture must still appear at the call site
1031///   as `nil` whenever a later positional arg is present, otherwise Swift slots
1032///   subsequent values into the wrong parameter.
1033fn build_args_and_setup(
1034    input: &serde_json::Value,
1035    args: &[crate::config::ArgMapping],
1036    fixture_id: &str,
1037    has_host_root_route: bool,
1038    function_name: &str,
1039    options_via: Option<&str>,
1040    options_type: Option<&str>,
1041    handle_config_fn: Option<&str>,
1042    visitor_handle_expr: Option<&str>,
1043    is_method_call: bool,
1044    module_name: &str,
1045    unnamed_arg_indices: &[usize],
1046) -> (Vec<String>, String) {
1047    if args.is_empty() {
1048        return (Vec::new(), String::new());
1049    }
1050
1051    let mut setup_lines: Vec<String> = Vec::new();
1052    let mut parts: Vec<(usize, String)> = Vec::new();
1053
1054    // Pre-compute, for each arg index, whether any later arg has a fixture-provided
1055    // value (or is required and will emit a default). When an optional arg is empty
1056    // but a later arg WILL emit, we must keep the slot with `nil` so positional
1057    // alignment is preserved.
1058    let later_emits: Vec<bool> = (0..args.len())
1059        .map(|i| {
1060            args.iter().skip(i + 1).any(|a| {
1061                let f = a.field.strip_prefix("input.").unwrap_or(&a.field);
1062                let v = input.get(f);
1063                let has_value = matches!(v, Some(x) if !x.is_null());
1064                has_value || !a.optional || (a.arg_type == "json_object" && a.name == "config")
1065            })
1066        })
1067        .collect();
1068
1069    for (idx, arg) in args.iter().enumerate() {
1070        if arg.arg_type == "mock_url" {
1071            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_ascii_uppercase().replace('-', "_"));
1072            let url_expr = if has_host_root_route {
1073                format!(
1074                    "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\")"
1075                )
1076            } else {
1077                format!("ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"")
1078            };
1079            setup_lines.push(format!("let {} = {url_expr}", arg.name));
1080            parts.push((idx, arg.name.clone()));
1081            continue;
1082        }
1083
1084        if arg.arg_type == "handle" {
1085            let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
1086            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1087            let config_val = input.get(field);
1088            let has_config = config_val
1089                .is_some_and(|v| !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty())));
1090            // Swift binding's engine factory declares `createEngine(config: ConfigType?)`,
1091            // so calls require the `config:` argument label even when passing `nil`.
1092            if has_config {
1093                if let Some(from_json_fn) = handle_config_fn {
1094                    let json_str = serde_json::to_string(config_val.unwrap()).unwrap_or_default();
1095                    let escaped = escape_swift_str(&json_str);
1096                    let config_var = format!("{}Config", arg.name.to_lower_camel_case());
1097                    setup_lines.push(format!("let {config_var} = try {from_json_fn}(\"{escaped}\")"));
1098                    setup_lines.push(format!("let {var_name} = try createEngine(config: {config_var})"));
1099                } else {
1100                    setup_lines.push(format!("let {var_name} = try createEngine(config: nil)"));
1101                }
1102            } else {
1103                setup_lines.push(format!("let {var_name} = try createEngine(config: nil)"));
1104            }
1105            parts.push((idx, var_name));
1106            continue;
1107        }
1108
1109        // bytes args: behavior depends on whether this is an e2e async wrapper (e.g. extractBytes
1110        // with unnamed_arg_indices) or a regular binding function. E2e wrappers take path strings
1111        // and read files internally; regular bindings accept [UInt8] arrays or strings.
1112        // When unnamed_arg_indices includes this arg index, it's an e2e wrapper that expects
1113        // a path string — pass it directly. Otherwise, emit Swift-native [UInt8] arrays.
1114        if arg.arg_type == "bytes" {
1115            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1116            let val = input.get(field);
1117
1118            // If this arg is unnamed (e2e wrapper signature), pass path strings directly.
1119            // E2e wrappers like `extractBytes(_ filePath: String, ...) async` read files internally.
1120            let is_unnamed_arg = unnamed_arg_indices.contains(&idx);
1121
1122            if is_unnamed_arg {
1123                // E2e async wrapper: expects path string, reads internally
1124                match val {
1125                    None | Some(serde_json::Value::Null) if arg.optional => {
1126                        if later_emits[idx] {
1127                            parts.push((idx, "nil".to_string()));
1128                        }
1129                    }
1130                    None | Some(serde_json::Value::Null) => {
1131                        parts.push((idx, "\"\"".to_string()));
1132                    }
1133                    Some(serde_json::Value::String(s)) => {
1134                        let escaped = escape_swift(s);
1135                        parts.push((idx, format!("\"{}\"", escaped)));
1136                    }
1137                    Some(other) => {
1138                        // Fallback: convert to JSON string
1139                        let json_str = serde_json::to_string(other).unwrap_or_default();
1140                        let escaped = escape_swift(&json_str);
1141                        parts.push((idx, format!("\"{}\"", escaped)));
1142                    }
1143                }
1144            } else {
1145                // Regular binding: emit Swift-native [UInt8] arrays
1146                match val {
1147                    None | Some(serde_json::Value::Null) if arg.optional => {
1148                        if later_emits[idx] {
1149                            parts.push((idx, "nil".to_string()));
1150                        }
1151                    }
1152                    None | Some(serde_json::Value::Null) => {
1153                        // Empty byte array
1154                        parts.push((idx, "[UInt8]()".to_string()));
1155                    }
1156                    Some(serde_json::Value::String(s)) => {
1157                        let escaped = escape_swift(s);
1158                        let var_name = format!("{}Bytes", arg.name.to_lower_camel_case());
1159                        let data_var = format!("{}Data", arg.name.to_lower_camel_case());
1160                        setup_lines.push(format!(
1161                            "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
1162                        ));
1163                        // Convert Data to [UInt8] array
1164                        setup_lines.push(format!("let {var_name} = Array({data_var})"));
1165                        parts.push((idx, var_name));
1166                    }
1167                    Some(serde_json::Value::Array(arr)) => {
1168                        // Inline byte array literal
1169                        let bytes: Vec<String> = arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1170                        parts.push((idx, format!("[UInt8]({})", bytes.join(", "))));
1171                    }
1172                    Some(other) => {
1173                        // Fallback: encode the JSON serialisation as UTF-8 bytes.
1174                        let json_str = serde_json::to_string(other).unwrap_or_default();
1175                        let escaped = escape_swift(&json_str);
1176                        let var_name = format!("{}Bytes", arg.name.to_lower_camel_case());
1177                        setup_lines.push(format!("let {var_name} = Array(\"{escaped}\".utf8)"));
1178                        parts.push((idx, var_name));
1179                    }
1180                }
1181            }
1182            continue;
1183        }
1184
1185        // json_object "config" args: behavior depends on whether this is an e2e wrapper or regular binding.
1186        // E2e wrappers (all args in unnamed_arg_indices) take JSON strings and deserialize internally.
1187        // Regular bindings (config arg not unnamed) expect deserialized objects (via options_via or default helper).
1188        // Batch functions (batchExtract*) hardcode config internally — skip it.
1189        let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
1190        let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
1191        if is_config_arg && !is_batch_fn {
1192            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1193            let val = input.get(field);
1194            let json_str = match val {
1195                None | Some(serde_json::Value::Null) => "{}".to_string(),
1196                Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1197            };
1198            let escaped = escape_swift(&json_str);
1199
1200            // Detect if config arg is unnamed (index `idx` in unnamed_arg_indices).
1201            // E2e wrappers keep config unnamed and receive JSON strings.
1202            let config_is_unnamed = unnamed_arg_indices.contains(&idx);
1203
1204            if config_is_unnamed {
1205                // E2e wrapper: pass JSON string directly (positional, no label).
1206                parts.push((idx, format!("\"{}\"", escaped)));
1207            } else {
1208                // Regular binding: deserialize to an opaque object.
1209                let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
1210                // Derive the from-json helper name from options_type (set on the call config),
1211                // or default to extractionConfigFromJson for backward compatibility.
1212                let from_json_fn = if let Some(type_name) = options_type {
1213                    format!("{}FromJson", type_name.to_lower_camel_case())
1214                } else {
1215                    "extractionConfigFromJson".to_string()
1216                };
1217                // Qualify with module name to avoid ambiguity when both Kreuzberg and RustBridge are imported.
1218                setup_lines.push(format!(
1219                    "let {var_name} = try {module_name}.{from_json_fn}(\"{escaped}\")"
1220                ));
1221                parts.push((idx, var_name));
1222            }
1223            continue;
1224        }
1225
1226        // json_object non-config args with options_via = "from_json":
1227        // Use the generated `{typeCamelCase}FromJson(_:)` helper so the fixture JSON is
1228        // deserialised into the opaque swift-bridge type rather than passed as a raw string.
1229        // When arg.field == "input", the entire fixture input IS the request object.
1230        // When a visitor handle is present, use `{typeCamelCase}FromJsonWithVisitor(json, handle)`
1231        // instead to attach the visitor to the options in one step.
1232        if arg.arg_type == "json_object" && options_via == Some("from_json") {
1233            if let Some(type_name) = options_type {
1234                let resolved_val = super::resolve_field(input, &arg.field);
1235                let json_str = match resolved_val {
1236                    serde_json::Value::Null => "{}".to_string(),
1237                    v => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1238                };
1239                let escaped = escape_swift(&json_str);
1240                let var_name = format!("_{}", arg.name.to_lower_camel_case());
1241                if let Some(handle_expr) = visitor_handle_expr {
1242                    // Use the visitor-aware helper: `{typeCamelCase}FromJsonWithVisitor(json, handle)`.
1243                    // The handle expression builds a VisitorHandle from the local class instance.
1244                    // The function name mirrors emit_options_field_options_helper: camelCase of
1245                    // `{options_snake}_from_json_with_visitor`.
1246                    let with_visitor_fn = format!("{}FromJsonWithVisitor", type_name.to_lower_camel_case());
1247                    let handle_var = format!("_visitorHandle_{}", var_name.trim_start_matches('_'));
1248                    setup_lines.push(format!("let {handle_var} = {handle_expr}"));
1249                    setup_lines.push(format!(
1250                        "let {var_name} = try {module_name}.{with_visitor_fn}(\"{escaped}\", {handle_var})"
1251                    ));
1252                } else {
1253                    let from_json_fn = format!("{}FromJson", type_name.to_lower_camel_case());
1254                    setup_lines.push(format!(
1255                        "let {var_name} = try {module_name}.{from_json_fn}(\"{escaped}\")"
1256                    ));
1257                }
1258                parts.push((idx, var_name));
1259                continue;
1260            }
1261        }
1262
1263        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1264        let val = input.get(field);
1265        match val {
1266            None | Some(serde_json::Value::Null) if arg.optional => {
1267                // Optional arg with no fixture value: keep the slot with `nil`
1268                // when a later arg will emit, so positional alignment matches
1269                // the swift-bridge wrapper signature.
1270                if later_emits[idx] {
1271                    parts.push((idx, "nil".to_string()));
1272                }
1273            }
1274            None | Some(serde_json::Value::Null) => {
1275                let default_val = match arg.arg_type.as_str() {
1276                    "string" => "\"\"".to_string(),
1277                    "int" | "integer" => "0".to_string(),
1278                    "float" | "number" => "0.0".to_string(),
1279                    "bool" | "boolean" => "false".to_string(),
1280                    _ => "nil".to_string(),
1281                };
1282                parts.push((idx, default_val));
1283            }
1284            Some(v) => {
1285                parts.push((idx, json_to_swift(v)));
1286            }
1287        }
1288    }
1289
1290    // Method calls on the DefaultClient handle (e.g. `_client.chat(req)`) use
1291    // anonymous Swift argument labels (`func chat(_ req:)`), so omit `name:` prefixes.
1292    // Free-function calls (e.g. `process(source:, config:)`) keep labelled args.
1293    // Swift argument labels must be camelCase, so convert from snake_case.
1294    // Some APIs like detectMimeTypeFromBytes take unnamed first parameters —
1295    // omit labels for indices listed in unnamed_arg_indices.
1296    let args_str = parts
1297        .into_iter()
1298        .map(|(idx, val)| {
1299            if is_method_call || unnamed_arg_indices.contains(&idx) {
1300                val
1301            } else {
1302                let label = args[idx].name.to_lower_camel_case();
1303                format!("{label}: {val}")
1304            }
1305        })
1306        .collect::<Vec<_>>()
1307        .join(", ");
1308    (setup_lines, args_str)
1309}
1310
1311#[allow(clippy::too_many_arguments)]
1312fn render_assertion(
1313    out: &mut String,
1314    assertion: &Assertion,
1315    result_var: &str,
1316    field_resolver: &FieldResolver,
1317    result_is_simple: bool,
1318    result_is_array: bool,
1319    result_is_option: bool,
1320    result_element_is_string: bool,
1321    result_field_accessor: &HashMap<String, String>,
1322    enum_fields: &HashSet<String>,
1323    is_streaming: bool,
1324) {
1325    // When the bare result is `Optional<T>` (no field path) the opaque class
1326    // exposed by swift-bridge has no `.toString()` method, so the usual
1327    // `.toString().isEmpty` pattern produces compile errors. Detect the
1328    // "bare result" case and prefer `XCTAssertNil` / `XCTAssertNotNil`.
1329    let bare_result_is_option = result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1330    // Streaming virtual fields resolve against the `chunks` collected-array variable.
1331    // Intercept before is_valid_for_result so they are never skipped.
1332    // Also intercept `usage.*` deep-paths in streaming tests: `AsyncThrowingStream` does
1333    // not have a `usage()` method, so we must route them through the chunks accessor.
1334    if let Some(f) = &assertion.field {
1335        let is_streaming_usage_path =
1336            is_streaming && (f == "usage" || (f.starts_with("usage.") || f.starts_with("usage[")));
1337        // Only route through the streaming-virtual `chunks` accessor when this is
1338        // actually a streaming fixture. Non-streaming fixtures (e.g. `process()`
1339        // with `chunkMaxSize`) expose `chunks` as a real `ProcessResult` field, so
1340        // emit `result.chunks()` via the regular field-accessor path below.
1341        if is_streaming
1342            && !f.is_empty()
1343            && (crate::codegen::streaming_assertions::is_streaming_virtual_field(f) || is_streaming_usage_path)
1344        {
1345            if let Some(expr) =
1346                crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "swift", "chunks")
1347            {
1348                let line = match assertion.assertion_type.as_str() {
1349                    "count_min" => {
1350                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1351                            format!("        XCTAssertGreaterThanOrEqual(chunks.count, {n})\n")
1352                        } else {
1353                            String::new()
1354                        }
1355                    }
1356                    "count_equals" => {
1357                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1358                            format!("        XCTAssertEqual(chunks.count, {n})\n")
1359                        } else {
1360                            String::new()
1361                        }
1362                    }
1363                    "equals" => {
1364                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1365                            let escaped = escape_swift(s);
1366                            format!("        XCTAssertEqual({expr}, \"{escaped}\")\n")
1367                        } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1368                            format!("        XCTAssertEqual({expr}, {b})\n")
1369                        } else {
1370                            String::new()
1371                        }
1372                    }
1373                    "not_empty" => {
1374                        format!("        XCTAssertFalse({expr}.isEmpty, \"expected non-empty\")\n")
1375                    }
1376                    "is_empty" => {
1377                        format!("        XCTAssertTrue({expr}.isEmpty, \"expected empty\")\n")
1378                    }
1379                    "is_true" => {
1380                        format!("        XCTAssertTrue({expr})\n")
1381                    }
1382                    "is_false" => {
1383                        format!("        XCTAssertFalse({expr})\n")
1384                    }
1385                    "greater_than" => {
1386                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1387                            format!("        XCTAssertGreaterThan(chunks.count, {n})\n")
1388                        } else {
1389                            String::new()
1390                        }
1391                    }
1392                    "contains" => {
1393                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1394                            let escaped = escape_swift(s);
1395                            format!(
1396                                "        XCTAssertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1397                            )
1398                        } else {
1399                            String::new()
1400                        }
1401                    }
1402                    _ => format!(
1403                        "        // streaming field '{f}': assertion type '{}' not rendered\n",
1404                        assertion.assertion_type
1405                    ),
1406                };
1407                if !line.is_empty() {
1408                    out.push_str(&line);
1409                }
1410            }
1411            return;
1412        }
1413    }
1414
1415    // Skip assertions on fields that don't exist on the result type.
1416    if let Some(f) = &assertion.field {
1417        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1418            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
1419            return;
1420        }
1421    }
1422
1423    // Skip assertions that traverse a tagged-union variant boundary.
1424    // In Swift, FormatMetadata and similar enum-backed opaque types are exposed as
1425    // plain classes by swift-bridge — variant accessor methods (e.g., `.excel()`)
1426    // are not generated, so such assertions cannot be expressed.
1427    if let Some(f) = &assertion.field {
1428        if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1429            let _ = writeln!(
1430                out,
1431                "        // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
1432            );
1433            return;
1434        }
1435    }
1436
1437    // Determine if this field is an enum type.
1438    let field_is_enum = assertion
1439        .field
1440        .as_deref()
1441        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1442
1443    let field_is_optional = assertion.field.as_deref().is_some_and(|f| {
1444        !f.is_empty() && (field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f)))
1445    });
1446    let field_is_array = assertion.field.as_deref().is_some_and(|f| {
1447        !f.is_empty()
1448            && (field_resolver.is_array(f)
1449                || field_resolver.is_array(field_resolver.resolve(f))
1450                || field_resolver.is_collection_root(f)
1451                || field_resolver.is_collection_root(field_resolver.resolve(f)))
1452    });
1453
1454    let field_expr_raw = if result_is_simple {
1455        result_var.to_string()
1456    } else {
1457        match &assertion.field {
1458            Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
1459            _ => result_var.to_string(),
1460        }
1461    };
1462
1463    // swift-bridge `RustVec<T>` exposes its elements as `T.SelfRef`, which holds
1464    // a raw pointer into the parent Vec's storage. When the Vec is a temporary
1465    // (e.g. `result.json_ld()` called inline), Swift ARC may release it before
1466    // the ref is used, leaving the ref's pointer dangling. Materialise the
1467    // temporary into a local so it survives the full expression chain.
1468    //
1469    // The local name is suffixed with the assertion type plus a hash of the
1470    // assertion's discriminating fields so multiple assertions on the same
1471    // collection don't redeclare the same name.
1472    let local_suffix = {
1473        use std::hash::{Hash, Hasher};
1474        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1475        assertion.field.hash(&mut hasher);
1476        assertion
1477            .value
1478            .as_ref()
1479            .map(|v| v.to_string())
1480            .unwrap_or_default()
1481            .hash(&mut hasher);
1482        format!(
1483            "{}_{:x}",
1484            assertion.assertion_type.replace(['-', '.'], "_"),
1485            hasher.finish() & 0xffff_ffff,
1486        )
1487    };
1488    let (vec_setup, field_expr, is_map_subscript) = materialise_vec_temporaries(&field_expr_raw, &local_suffix);
1489    // The `contains` / `not_contains` traversal branch builds its own
1490    // accessor from `field_resolver.accessor(array_part, ...)`, ignoring
1491    // `field_expr`. Emitting the vec_setup there would produce dead
1492    // `let _vec_… = …` lines, so skip it for those traversal cases.
1493    let field_uses_traversal = assertion.field.as_deref().is_some_and(|f| f.contains("[]."));
1494    let traversal_skips_field_expr = field_uses_traversal
1495        && matches!(
1496            assertion.assertion_type.as_str(),
1497            "contains" | "not_contains" | "not_empty" | "is_empty"
1498        );
1499    if !traversal_skips_field_expr {
1500        for line in &vec_setup {
1501            let _ = writeln!(out, "        {line}");
1502        }
1503    }
1504
1505    // In Swift, optional chaining with `?.` makes the result optional even if the
1506    // called method's return type isn't marked optional. For example:
1507    // `result.markdown()?.content()` returns `Optional<RustString>` because
1508    // `markdown()` is optional and the `?.` operator wraps the result.
1509    // Detect this by checking if the accessor contains `?.`.
1510    let accessor_is_optional = field_expr.contains("?.");
1511    // First-class Codable Swift struct property access leaves no trailing `()`
1512    // on the leaf segment — e.g. `result.text` (Swift `String`) vs
1513    // `result.text()` (RustBridge.RustString). When the leaf is property
1514    // access, we already have a Swift `String` (or `String?`) and must NOT
1515    // re-wrap with `.toString()`. Detect this by looking at the final segment
1516    // after the last `.` — property access ends in a bare identifier (no
1517    // trailing `()` or `()?`).
1518    let leaf_is_property_access = {
1519        let trimmed = field_expr.trim_end_matches('?');
1520        // Skip subscripts: `name?[0]` should still see `name` as the field.
1521        let last_segment = trimmed.rsplit_once('.').map(|(_, s)| s).unwrap_or(trimmed);
1522        let last_segment = last_segment.split('[').next().unwrap_or(last_segment);
1523        !last_segment.ends_with(')') && !last_segment.is_empty()
1524    };
1525
1526    // Bare-result Option<T> case: the call returns `Optional<String>` (or
1527    // similar) so the field_expr is `result` typed as `String?`. String
1528    // assertions like `XCTAssertEqual(result.trimmingCharacters(...), …)` will
1529    // not compile against an optional — coalesce to `""` so the macro sees a
1530    // concrete Swift `String`.
1531    let bare_result_is_simple_option =
1532        result_is_simple && result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1533
1534    // For enum fields, need to handle the string representation differently in Swift.
1535    // Swift enums don't have `.rawValue` unless they're explicitly RawRepresentable.
1536    // Check if this is an enum type and handle accordingly.
1537    // For optional fields (Optional<RustString>), use optional chaining before toString().
1538    // For other fields: swift-bridge returns all Rust `String` fields as `RustString`.
1539    // We add .toString() here so string assertions (contains, hasPrefix, etc.) work.
1540    // Non-string opaque fields (DocumentStructure, etc.) should not appear in string
1541    // assertions — the fixture schema controls which assertions apply to which fields.
1542    let string_expr = if is_map_subscript {
1543        // The field_expr already evaluates to `String?` (from a JSON-decoded
1544        // `[String: String]` subscript). No `.toString()` chain needed —
1545        // coalesce the optional to "" and use the Swift String directly.
1546        format!("({field_expr} ?? \"\")")
1547    } else if leaf_is_property_access {
1548        // First-class Codable struct field access: leaf is already a Swift
1549        // `String` (or `String?`/enum type) — never a `RustString` requiring
1550        // `.toString()`. For optional leaves, coalesce to "" so XCTAssert
1551        // receives a non-optional Swift `String`.
1552        if field_is_enum && (field_is_optional || accessor_is_optional) {
1553            // Optional first-class Codable enum (e.g. `FinishReason?` where
1554            // `FinishReason: String, Codable`). `.rawValue` gives the serde
1555            // wire value (e.g. "tool_calls") so assertions match fixture JSON.
1556            format!("(({field_expr})?.rawValue ?? \"\")")
1557        } else if field_is_enum {
1558            format!("{field_expr}.rawValue")
1559        } else if field_is_optional || accessor_is_optional || bare_result_is_simple_option {
1560            format!("({field_expr} ?? \"\")")
1561        } else {
1562            field_expr.to_string()
1563        }
1564    } else if field_is_enum && (field_is_optional || accessor_is_optional) {
1565        // Enum-typed fields that are also optional (e.g. `finish_reason() -> Optional<RustString>`)
1566        // must use optional chaining: `?.toString() ?? ""` to unwrap before converting to Swift String.
1567        format!("({field_expr}?.toString() ?? \"\")")
1568    } else if field_is_enum {
1569        // Enum-typed fields are now bridged as `String` (RustString in Swift) rather than
1570        // as opaque enum handles. The getter on the Rust side calls `to_string()` internally
1571        // and returns a `String` across the FFI. In Swift this arrives as `RustString`, so
1572        // `.toString()` converts it to a Swift `String` — one call, not two.
1573        format!("{field_expr}.toString()")
1574    } else if field_is_optional {
1575        // Leaf field itself is Optional<RustString> — need ?.toString() to unwrap.
1576        format!("({field_expr}?.toString() ?? \"\")")
1577    } else if accessor_is_optional {
1578        // Ancestor optional chain propagates; leaf is non-optional RustString within chain.
1579        // Use .toString() directly — the whole expr is Optional<String> due to propagation.
1580        format!("({field_expr}.toString() ?? \"\")")
1581    } else {
1582        format!("{field_expr}.toString()")
1583    };
1584
1585    match assertion.assertion_type.as_str() {
1586        "equals" => {
1587            if let Some(expected) = &assertion.value {
1588                let swift_val = json_to_swift(expected);
1589                if expected.is_string() {
1590                    if field_is_enum {
1591                        // Enum fields: `to_string()` (snake_case) returns RustString;
1592                        // `.toString()` converts it to a Swift String.
1593                        // `string_expr` already incorporates this call chain.
1594                        let trim_expr =
1595                            format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)");
1596                        let _ = writeln!(out, "        XCTAssertEqual({trim_expr}, {swift_val})");
1597                    } else {
1598                        // For optional strings (String?), use ?? to coalesce before trimming.
1599                        // `.toString()` converts RustString → Swift String before calling
1600                        // `.trimmingCharacters`, which requires a concrete String type.
1601                        // string_expr already incorporates field_is_optional via ?.toString() ?? "".
1602                        let trim_expr =
1603                            format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)");
1604                        let _ = writeln!(out, "        XCTAssertEqual({trim_expr}, {swift_val})");
1605                    }
1606                } else {
1607                    // For numeric fields, cast the expected value to match the field's type (e.g., UInt).
1608                    let cast_swift_val = swift_numeric_literal_cast(&field_expr, &swift_val);
1609                    let _ = writeln!(out, "        XCTAssertEqual({field_expr}, {cast_swift_val})");
1610                }
1611            }
1612        }
1613        "contains" => {
1614            if let Some(expected) = &assertion.value {
1615                let swift_val = json_to_swift(expected);
1616                // When the root result IS the array (result_is_simple + result_is_array) and
1617                // there is no field path, check array membership via map+contains.
1618                let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
1619                if result_is_simple && result_is_array && no_field {
1620                    if result_element_is_string {
1621                        // The Swift binding exposes the result as a native
1622                        // `[String]` (e.g. `manifestLanguages() -> [String]`),
1623                        // not the opaque `RustVec<RustString>`. Iterating
1624                        // elements yields plain Swift `String`, which has no
1625                        // `asStr()` — emit a direct `.contains(...)` instead.
1626                        let _ = writeln!(
1627                            out,
1628                            "        XCTAssertTrue({result_var}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1629                        );
1630                    } else {
1631                        // RustVec<RustString> iteration yields RustStringRef (no `toString()`);
1632                        // use `.asStr().toString()` to convert each element to a Swift String.
1633                        // swift-bridge renames `as_str` → `asStr` automatically.
1634                        let _ = writeln!(
1635                            out,
1636                            "        XCTAssertTrue({result_var}.map {{ $0.asStr().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1637                        );
1638                    }
1639                } else {
1640                    // []. traversal: field like "links[].url" → contains(where:) closure.
1641                    let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1642                        if let Some(dot) = f.find("[].") {
1643                            let array_part = &f[..dot];
1644                            let elem_part = &f[dot + 3..];
1645                            let line = swift_traversal_contains_assert(
1646                                array_part,
1647                                elem_part,
1648                                f,
1649                                &swift_val,
1650                                result_var,
1651                                false,
1652                                &format!("expected to contain: \\({swift_val})"),
1653                                enum_fields,
1654                                field_resolver,
1655                            );
1656                            let _ = writeln!(out, "{line}");
1657                            true
1658                        } else {
1659                            false
1660                        }
1661                    } else {
1662                        false
1663                    };
1664                    if !traversal_handled {
1665                        // For array fields (RustVec<RustString>), check membership via map+contains.
1666                        let field_is_array = assertion
1667                            .field
1668                            .as_deref()
1669                            .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1670                        if field_is_array {
1671                            // First try the "stringy aggregator" path: when the array element
1672                            // is an opaque DTO with several text-bearing accessors (e.g.
1673                            // ImportInfo with source/items/alias, or StructureItem with
1674                            // kind/name/signature/...), emit a `contains(where: { ... })`
1675                            // closure that walks every accessor and does substring matching,
1676                            // mirroring python's `_alef_e2e_item_texts`. This avoids the
1677                            // brittle "primary accessor" guess (e.g. ImportInfo → source
1678                            // misses imports whose name lives in `items`).
1679                            let aggregator = swift_stringy_aggregator_contains_assert(
1680                                assertion.field.as_deref(),
1681                                result_var,
1682                                field_resolver,
1683                                &swift_val,
1684                            );
1685                            if let Some(line) = aggregator {
1686                                let _ = writeln!(out, "{line}");
1687                            } else {
1688                                let (contains_expr, is_optional) = swift_array_contains_expr(
1689                                    assertion.field.as_deref(),
1690                                    result_var,
1691                                    field_resolver,
1692                                    result_field_accessor,
1693                                );
1694                                let wrapped = if is_optional {
1695                                    format!("({contains_expr} ?? [])")
1696                                } else {
1697                                    contains_expr
1698                                };
1699                                let _ = writeln!(
1700                                    out,
1701                                    "        XCTAssertTrue({wrapped}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1702                                );
1703                            }
1704                        } else if field_is_enum {
1705                            // Enum fields: use `toString().toString()` (via string_expr) to get the
1706                            // serde variant name as a Swift String, then check substring containment.
1707                            // Swift's `String.contains("")` returns false; guard with `.isEmpty` so
1708                            // fixtures that assert containment of an empty string still pass.
1709                            let _ = writeln!(
1710                                out,
1711                                "        XCTAssertTrue({swift_val}.isEmpty || {string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1712                            );
1713                        } else {
1714                            // Same `isEmpty` guard as the enum branch — every string trivially
1715                            // "contains" the empty string, but Swift's `String.contains` does not.
1716                            let _ = writeln!(
1717                                out,
1718                                "        XCTAssertTrue({swift_val}.isEmpty || {string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1719                            );
1720                        }
1721                    }
1722                }
1723            }
1724        }
1725        "contains_all" => {
1726            if let Some(values) = &assertion.values {
1727                // []. traversal: field like "links[].link_type" → contains(where:) per value.
1728                if let Some(f) = assertion.field.as_deref() {
1729                    if let Some(dot) = f.find("[].") {
1730                        let array_part = &f[..dot];
1731                        let elem_part = &f[dot + 3..];
1732                        for val in values {
1733                            let swift_val = json_to_swift(val);
1734                            let line = swift_traversal_contains_assert(
1735                                array_part,
1736                                elem_part,
1737                                f,
1738                                &swift_val,
1739                                result_var,
1740                                false,
1741                                &format!("expected to contain: \\({swift_val})"),
1742                                enum_fields,
1743                                field_resolver,
1744                            );
1745                            let _ = writeln!(out, "{line}");
1746                        }
1747                        // handled — skip remaining branches
1748                    } else {
1749                        // For array fields (RustVec<RustString>), check membership via map+contains.
1750                        let field_is_array = field_resolver.is_array(field_resolver.resolve(f));
1751                        if field_is_array {
1752                            let (contains_expr, is_optional) = swift_array_contains_expr(
1753                                assertion.field.as_deref(),
1754                                result_var,
1755                                field_resolver,
1756                                result_field_accessor,
1757                            );
1758                            let wrapped = if is_optional {
1759                                format!("({contains_expr} ?? [])")
1760                            } else {
1761                                contains_expr
1762                            };
1763                            for val in values {
1764                                let swift_val = json_to_swift(val);
1765                                let _ = writeln!(
1766                                    out,
1767                                    "        XCTAssertTrue({wrapped}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1768                                );
1769                            }
1770                        } else if field_is_enum {
1771                            // Enum fields: use `toString().toString()` (via string_expr) to get the
1772                            // serde variant name as a Swift String, then check substring containment.
1773                            for val in values {
1774                                let swift_val = json_to_swift(val);
1775                                let _ = writeln!(
1776                                    out,
1777                                    "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1778                                );
1779                            }
1780                        } else {
1781                            for val in values {
1782                                let swift_val = json_to_swift(val);
1783                                let _ = writeln!(
1784                                    out,
1785                                    "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1786                                );
1787                            }
1788                        }
1789                    }
1790                } else {
1791                    // No field — fall back to existing string_expr path.
1792                    for val in values {
1793                        let swift_val = json_to_swift(val);
1794                        let _ = writeln!(
1795                            out,
1796                            "        XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1797                        );
1798                    }
1799                }
1800            }
1801        }
1802        "not_contains" => {
1803            if let Some(expected) = &assertion.value {
1804                let swift_val = json_to_swift(expected);
1805                // []. traversal: "links[].url" → XCTAssertFalse(array.contains(where:))
1806                let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1807                    if let Some(dot) = f.find("[].") {
1808                        let array_part = &f[..dot];
1809                        let elem_part = &f[dot + 3..];
1810                        let line = swift_traversal_contains_assert(
1811                            array_part,
1812                            elem_part,
1813                            f,
1814                            &swift_val,
1815                            result_var,
1816                            true,
1817                            &format!("expected NOT to contain: \\({swift_val})"),
1818                            enum_fields,
1819                            field_resolver,
1820                        );
1821                        let _ = writeln!(out, "{line}");
1822                        true
1823                    } else {
1824                        false
1825                    }
1826                } else {
1827                    false
1828                };
1829                if !traversal_handled {
1830                    let _ = writeln!(
1831                        out,
1832                        "        XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
1833                    );
1834                }
1835            }
1836        }
1837        "not_empty" => {
1838            // For optional fields (Optional<T>), check that the value is non-nil.
1839            // For array fields (RustVec<T>), check .isEmpty on the vec directly.
1840            // For result_is_simple (e.g. Data, String), use .isEmpty directly on
1841            // the result — avoids calling .toString() on non-RustString types.
1842            // For string fields, convert to Swift String and check .isEmpty.
1843            // []. traversal: "links[].url" → contains(where: { !elem.isEmpty })
1844            let traversal_not_empty_handled = if let Some(f) = assertion.field.as_deref() {
1845                if let Some(dot) = f.find("[].") {
1846                    let array_part = &f[..dot];
1847                    let elem_part = &f[dot + 3..];
1848                    let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1849                    let resolved_full = field_resolver.resolve(f);
1850                    let resolved_elem_part = resolved_full
1851                        .find("[].")
1852                        .map(|d| &resolved_full[d + 3..])
1853                        .unwrap_or(elem_part);
1854                    let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1855                    let elem_is_enum = enum_fields.contains(f) || enum_fields.contains(resolved_full);
1856                    let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1857                        || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1858                    let elem_str = if elem_is_enum {
1859                        format!("{elem_accessor}.to_string().toString()")
1860                    } else if elem_is_optional {
1861                        format!("({elem_accessor}?.toString() ?? \"\")")
1862                    } else {
1863                        format!("{elem_accessor}.toString()")
1864                    };
1865                    let _ = writeln!(
1866                        out,
1867                        "        XCTAssertTrue({array_accessor}.contains(where: {{ !{elem_str}.isEmpty }}), \"expected non-empty value\")"
1868                    );
1869                    true
1870                } else {
1871                    false
1872                }
1873            } else {
1874                false
1875            };
1876            if !traversal_not_empty_handled {
1877                if bare_result_is_option {
1878                    let _ = writeln!(out, "        XCTAssertNotNil({result_var}, \"expected non-nil value\")");
1879                } else if field_is_optional {
1880                    let _ = writeln!(out, "        XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
1881                } else if field_is_array {
1882                    let _ = writeln!(
1883                        out,
1884                        "        XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
1885                    );
1886                } else if result_is_simple {
1887                    // result_is_simple: result is a primitive (Data, String, etc.) — use .isEmpty directly.
1888                    let _ = writeln!(
1889                        out,
1890                        "        XCTAssertFalse({result_var}.isEmpty, \"expected non-empty value\")"
1891                    );
1892                } else {
1893                    // First-class Swift struct fields are properties typed as native Swift
1894                    // `String` / `[T]` / `Data` etc — all of which expose `.count` (and
1895                    // `String`/`Array` also expose `.isEmpty`). Use `.count > 0` so the same
1896                    // path works whether the field is a String or an Array.
1897                    //
1898                    // When the accessor contains a `?.` optional chain, `.count` returns an
1899                    // Optional which Swift cannot compare directly to `0`; coalesce via `?? 0`
1900                    // so the assertion typechecks.
1901                    //
1902                    // For opaque method-call accessors (`result.id()`), the returned type is
1903                    // `RustString`, which lacks `.count`. Convert to Swift `String` first via
1904                    // `.toString()`. Array fields short-circuit above via `field_is_array`, so
1905                    // method-call accessors landing here are guaranteed to be the scalar /
1906                    // string flavour; vec accessors return `RustVec` (whose `.count` is fine).
1907                    let count_target = swift_count_target(&field_expr, field_resolver, assertion.field.as_deref());
1908                    let len_expr = if accessor_is_optional {
1909                        format!("({count_target}.count ?? 0)")
1910                    } else {
1911                        format!("{count_target}.count")
1912                    };
1913                    let _ = writeln!(
1914                        out,
1915                        "        XCTAssertGreaterThan({len_expr}, 0, \"expected non-empty value\")"
1916                    );
1917                }
1918            }
1919        }
1920        "is_empty" => {
1921            if bare_result_is_option {
1922                let _ = writeln!(out, "        XCTAssertNil({result_var}, \"expected nil value\")");
1923            } else if field_is_optional {
1924                let _ = writeln!(out, "        XCTAssertNil({field_expr}, \"expected nil value\")");
1925            } else if field_is_array {
1926                let _ = writeln!(
1927                    out,
1928                    "        XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
1929                );
1930            } else {
1931                // Symmetric with not_empty: use .count == 0 on first-class Swift types.
1932                // Wrap opaque method-call accessors (`result.id()`) with `.toString()` so
1933                // `.count` lands on Swift `String`, not `RustString` (which lacks `.count`).
1934                let count_target = swift_count_target(&field_expr, field_resolver, assertion.field.as_deref());
1935                let len_expr = if accessor_is_optional {
1936                    format!("({count_target}.count ?? 0)")
1937                } else {
1938                    format!("{count_target}.count")
1939                };
1940                let _ = writeln!(out, "        XCTAssertEqual({len_expr}, 0, \"expected empty value\")");
1941            }
1942        }
1943        "contains_any" => {
1944            if let Some(values) = &assertion.values {
1945                let checks: Vec<String> = values
1946                    .iter()
1947                    .map(|v| {
1948                        let swift_val = json_to_swift(v);
1949                        format!("{string_expr}.contains({swift_val})")
1950                    })
1951                    .collect();
1952                let joined = checks.join(" || ");
1953                let _ = writeln!(
1954                    out,
1955                    "        XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
1956                );
1957            }
1958        }
1959        "greater_than" => {
1960            if let Some(val) = &assertion.value {
1961                let swift_val = json_to_swift(val);
1962                // For optional numeric fields (or when the accessor chain is optional),
1963                // coalesce to 0 before comparing so the expression is non-optional.
1964                let field_is_optional = accessor_is_optional
1965                    || assertion.field.as_deref().is_some_and(|f| {
1966                        field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1967                    });
1968                let compare_expr = if field_is_optional {
1969                    let cast_val = swift_numeric_literal_cast(&field_expr, "0");
1970                    format!("({field_expr} ?? {cast_val})")
1971                } else {
1972                    field_expr.clone()
1973                };
1974                let cast_swift_val = swift_numeric_literal_cast(&field_expr, &swift_val);
1975                let _ = writeln!(out, "        XCTAssertGreaterThan({compare_expr}, {cast_swift_val})");
1976            }
1977        }
1978        "less_than" => {
1979            if let Some(val) = &assertion.value {
1980                let swift_val = json_to_swift(val);
1981                let field_is_optional = accessor_is_optional
1982                    || assertion.field.as_deref().is_some_and(|f| {
1983                        field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1984                    });
1985                let compare_expr = if field_is_optional {
1986                    let cast_val = swift_numeric_literal_cast(&field_expr, "0");
1987                    format!("({field_expr} ?? {cast_val})")
1988                } else {
1989                    field_expr.clone()
1990                };
1991                let cast_swift_val = swift_numeric_literal_cast(&field_expr, &swift_val);
1992                let _ = writeln!(out, "        XCTAssertLessThan({compare_expr}, {cast_swift_val})");
1993            }
1994        }
1995        "greater_than_or_equal" => {
1996            if let Some(val) = &assertion.value {
1997                let swift_val = json_to_swift(val);
1998                // For optional numeric fields (or when the accessor chain is optional),
1999                // coalesce to 0 before comparing so the expression is non-optional.
2000                let field_is_optional = accessor_is_optional
2001                    || assertion.field.as_deref().is_some_and(|f| {
2002                        field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
2003                    });
2004                let compare_expr = if field_is_optional {
2005                    let cast_val = swift_numeric_literal_cast(&field_expr, "0");
2006                    format!("({field_expr} ?? {cast_val})")
2007                } else {
2008                    field_expr.clone()
2009                };
2010                let cast_swift_val = swift_numeric_literal_cast(&field_expr, &swift_val);
2011                let _ = writeln!(
2012                    out,
2013                    "        XCTAssertGreaterThanOrEqual({compare_expr}, {cast_swift_val})"
2014                );
2015            }
2016        }
2017        "less_than_or_equal" => {
2018            if let Some(val) = &assertion.value {
2019                let swift_val = json_to_swift(val);
2020                let field_is_optional = accessor_is_optional
2021                    || assertion.field.as_deref().is_some_and(|f| {
2022                        field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
2023                    });
2024                let compare_expr = if field_is_optional {
2025                    let cast_val = swift_numeric_literal_cast(&field_expr, "0");
2026                    format!("({field_expr} ?? {cast_val})")
2027                } else {
2028                    field_expr.clone()
2029                };
2030                let cast_swift_val = swift_numeric_literal_cast(&field_expr, &swift_val);
2031                let _ = writeln!(
2032                    out,
2033                    "        XCTAssertLessThanOrEqual({compare_expr}, {cast_swift_val})"
2034                );
2035            }
2036        }
2037        "starts_with" => {
2038            if let Some(expected) = &assertion.value {
2039                let swift_val = json_to_swift(expected);
2040                let _ = writeln!(
2041                    out,
2042                    "        XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
2043                );
2044            }
2045        }
2046        "ends_with" => {
2047            if let Some(expected) = &assertion.value {
2048                let swift_val = json_to_swift(expected);
2049                let _ = writeln!(
2050                    out,
2051                    "        XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
2052                );
2053            }
2054        }
2055        "min_length" => {
2056            if let Some(val) = &assertion.value {
2057                if let Some(n) = val.as_u64() {
2058                    // Use string_expr.count: for RustString fields string_expr already has
2059                    // .toString() appended, giving a Swift String whose .count is character count.
2060                    let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
2061                }
2062            }
2063        }
2064        "max_length" => {
2065            if let Some(val) = &assertion.value {
2066                if let Some(n) = val.as_u64() {
2067                    let _ = writeln!(out, "        XCTAssertLessThanOrEqual({string_expr}.count, {n})");
2068                }
2069            }
2070        }
2071        "count_min" => {
2072            if let Some(val) = &assertion.value {
2073                if let Some(n) = val.as_u64() {
2074                    // For fields nested inside an optional parent (e.g. document.nodes where
2075                    // document is Optional), the accessor generates `result.document().nodes()`
2076                    // which doesn't compile in Swift without optional chaining.
2077                    let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
2078                    let _ = writeln!(out, "        XCTAssertGreaterThanOrEqual({count_expr}, {n})");
2079                }
2080            }
2081        }
2082        "count_equals" => {
2083            if let Some(val) = &assertion.value {
2084                if let Some(n) = val.as_u64() {
2085                    let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
2086                    let _ = writeln!(out, "        XCTAssertEqual({count_expr}, {n})");
2087                }
2088            }
2089        }
2090        "is_true" => {
2091            let _ = writeln!(out, "        XCTAssertTrue({field_expr})");
2092        }
2093        "is_false" => {
2094            let _ = writeln!(out, "        XCTAssertFalse({field_expr})");
2095        }
2096        "matches_regex" => {
2097            if let Some(expected) = &assertion.value {
2098                let swift_val = json_to_swift(expected);
2099                let _ = writeln!(
2100                    out,
2101                    "        XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
2102                );
2103            }
2104        }
2105        "not_error" => {
2106            // Already handled by the call succeeding without exception.
2107        }
2108        "error" => {
2109            // Handled at the test method level.
2110        }
2111        "method_result" => {
2112            let _ = writeln!(out, "        // method_result assertions not yet implemented for Swift");
2113        }
2114        other => {
2115            panic!("Swift e2e generator: unsupported assertion type: {other}");
2116        }
2117    }
2118}
2119
2120/// Build a Swift accessor path for the given fixture field, inserting `()` on
2121/// every segment and `?` after every optional non-leaf segment.
2122///
2123/// This is the core helper for count/contains helpers that need to reconstruct
2124/// the path with correct optional chaining from the raw fixture field name.
2125///
2126/// Rewrite a Swift accessor expression to capture any `RustVec` temporaries
2127/// in a local before subscripting them. Returns `(setup_lines, rewritten_expr)`.
2128///
2129/// swift-bridge's `Vec_<T>$get` returns a raw pointer into the Vec's storage
2130/// wrapped in a `T.SelfRef`. If the Vec was a temporary, ARC may release it
2131/// before the ref is dereferenced, leaving the pointer dangling and reads
2132/// returning empty/garbage. Hoisting the Vec into a `let` binding ties the
2133/// Vec's lifetime to the enclosing function scope, so the ref stays valid.
2134///
2135/// Only the first `()[...]` occurrence per expression is materialised — that
2136/// covers all current fixture access patterns (single-level subscripts on a
2137/// result field). Nested subscripts are rare and would need a more elaborate
2138/// pass; if they appear, this returns conservative output (just the first
2139/// hoist) which is still correct.
2140/// Returns `(setup_lines, rewritten_expr, is_map_subscript)`. `is_map_subscript` is
2141/// true when the subscript key was a string literal, indicating the parent
2142/// accessor returns a JSON-encoded Map (RustString) and the rewritten expression
2143/// already evaluates to `String?` so callers should NOT append `.toString()`.
2144fn materialise_vec_temporaries(expr: &str, name_suffix: &str) -> (Vec<String>, String, bool) {
2145    let Some(idx) = expr.find("()[") else {
2146        return (Vec::new(), expr.to_string(), false);
2147    };
2148    let after_open = idx + 3; // position after `()[`
2149    let Some(close_rel) = expr[after_open..].find(']') else {
2150        return (Vec::new(), expr.to_string(), false);
2151    };
2152    let subscript_end = after_open + close_rel; // index of `]`
2153    let prefix = &expr[..idx + 2]; // includes `()`
2154    let subscript = &expr[idx + 2..=subscript_end]; // `[N]`
2155    let tail = &expr[subscript_end + 1..]; // everything after `]`
2156    let method_dot = expr[..idx].rfind('.').unwrap_or(0);
2157    let method = &expr[method_dot + 1..idx];
2158    let local = format!("_vec_{}_{}", method, name_suffix);
2159
2160    // String-key subscript (e.g. `["title"]`) signals a Map-like access. swift-bridge
2161    // serialises non-leaf Maps (e.g. `HashMap<String, String>`) as JSON-encoded
2162    // RustString rather than exposing a Swift dictionary. Decode the RustString to
2163    // `[String: String]` before subscripting so `_vec_X["title"]` works.
2164    let inner = subscript.trim_start_matches('[').trim_end_matches(']');
2165    let is_string_key = inner.starts_with('"') && inner.ends_with('"');
2166    let setup = if is_string_key {
2167        format!(
2168            "let {local} = (try? JSONSerialization.jsonObject(with: ({prefix}.toString() ?? \"{{}}\").data(using: .utf8)!) as? [String: String]) ?? [:]"
2169        )
2170    } else {
2171        format!("let {local} = {prefix}")
2172    };
2173
2174    let rewritten = format!("{local}{subscript}{tail}");
2175    (vec![setup], rewritten, is_string_key)
2176}
2177
2178/// Returns `(accessor_expr, has_optional)` where `has_optional` is true when
2179/// at least one `?.` was inserted.
2180fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
2181    let resolved = field_resolver.resolve(field);
2182    let parts: Vec<&str> = resolved.split('.').collect();
2183
2184    // Track the current IR type as we walk segments so each segment can be
2185    // emitted with property syntax (first-class Codable struct) or method-call
2186    // syntax (typealias-to-`RustBridge.X`). Mirrors the per-segment dispatch in
2187    // `render_swift_with_first_class_map`.
2188    let mut current_type: Option<String> = field_resolver.swift_root_type().cloned();
2189    // Once a chain crosses a `[N]` subscript, we are operating on a RustVec
2190    // element, which is always the OPAQUE `RustBridge.T` (swift-bridge does not
2191    // convert RustVec elements into the first-class Codable struct). Pin
2192    // opaque method-call syntax after the first index step.
2193    let mut via_rust_vec = false;
2194    // Once a chain crosses an opaque (typealias-to-`RustBridge.X`) segment, every
2195    // subsequent accessor must also be opaque (method-call syntax). Calling a
2196    // method on `RustBridge.X` returns the OPAQUE wrapper of the next type, even
2197    // when that next type is independently eligible for first-class emission.
2198    // See `field_access::render_swift_with_first_class_map` for the matching
2199    // invariant. Without this, `metrics.total_lines` on an opaque parent emits
2200    // `.metrics().totalLines` instead of `.metrics().totalLines()`.
2201    let mut via_opaque = false;
2202
2203    let mut out = result_var.to_string();
2204    let mut has_optional = false;
2205    let mut path_so_far = String::new();
2206    let total = parts.len();
2207    for (i, part) in parts.iter().enumerate() {
2208        let is_leaf = i == total - 1;
2209        // Handle array index subscripts within a segment, e.g. `data[0]`.
2210        // `data[0]` must become `.data()[0]` (opaque) or `.data[0]` (first-class).
2211        // Split at the first `[` if present.
2212        let (field_name, subscript): (&str, Option<&str>) = if let Some(bracket_pos) = part.find('[') {
2213            (&part[..bracket_pos], Some(&part[bracket_pos..]))
2214        } else {
2215            (part, None)
2216        };
2217
2218        if !path_so_far.is_empty() {
2219            path_so_far.push('.');
2220        }
2221        // Build the base path (without subscript) for the optional check. When the
2222        // segment is e.g. `tool_calls[0]`, we want to check `is_optional` against
2223        // "choices[0].message.tool_calls" not "choices[0].message.tool_calls[0]".
2224        let base_path = {
2225            let mut p = path_so_far.clone();
2226            p.push_str(field_name);
2227            p
2228        };
2229        // Now push the full part (with subscript if any) so path_so_far is correct
2230        // for subsequent segment checks.
2231        path_so_far.push_str(part);
2232
2233        // First-class struct fields → property access (no `()`); typealias-to-
2234        // opaque fields → method-call access (`()`). Once we've indexed through
2235        // a RustVec, every subsequent segment is on an opaque element.
2236        // When current_type is None (opaque parent that doesn't appear in field_types),
2237        // treat it as opaque and use method-call syntax.
2238        let is_first_class = current_type
2239            .as_ref()
2240            .is_some_and(|t| field_resolver.swift_is_first_class(Some(t)));
2241        let property_syntax = !via_rust_vec && !via_opaque && is_first_class;
2242        if !property_syntax {
2243            via_opaque = true;
2244        }
2245        out.push('.');
2246        // Swift bindings (both first-class `public let` props and swift-bridge
2247        // method names) always use lowerCamelCase — never raw snake_case from IR.
2248        out.push_str(&field_name.to_lower_camel_case());
2249        if let Some(sub) = subscript {
2250            // When the getter for this subscripted field is itself optional
2251            // (e.g. tool_calls returns Optional<RustVec<T>>), insert `?` before
2252            // the subscript so Swift unwraps the Optional before indexing.
2253            let field_is_optional = field_resolver.is_optional(&base_path);
2254            let access = if property_syntax { "" } else { "()" };
2255            if field_is_optional {
2256                out.push_str(&format!("{access}?"));
2257                has_optional = true;
2258            } else {
2259                out.push_str(access);
2260            }
2261            out.push_str(sub);
2262            // Do NOT append a trailing `?` after the subscript index: in Swift,
2263            // `optionalVec?[N]` via `Collection.subscript` returns the element
2264            // type `T` directly. The parent `has_optional` flag is still set
2265            // when `field_is_optional` is true, which causes the enclosing
2266            // expression to be wrapped in `(... ?? fallback)` correctly.
2267            // Indexing into a Vec<Named> yields a Named element. Only pin opaque
2268            // syntax when the array itself was opaque (method-call); when the
2269            // owner is first-class, the array is a Swift `[T]` whose elements
2270            // are first-class T (property access).
2271            current_type = field_resolver.swift_advance(current_type.as_deref(), field_name);
2272            if !property_syntax {
2273                via_rust_vec = true;
2274            }
2275        } else {
2276            if !property_syntax {
2277                out.push_str("()");
2278            }
2279            // Insert `?` after the accessor for non-leaf optional fields so the
2280            // next member access becomes `?.`.
2281            if !is_leaf && field_resolver.is_optional(&base_path) {
2282                out.push('?');
2283                has_optional = true;
2284            }
2285            current_type = field_resolver.swift_advance(current_type.as_deref(), field_name);
2286        }
2287    }
2288    (out, has_optional)
2289}
2290
2291/// Generate a `[String]` (or `[String]?`) expression for a `RustVec<RustString>`
2292/// field so that `contains` membership checks work against plain Swift Strings.
2293///
2294/// We use `.map { $0.asStr().toString() }` because:
2295/// 1. Iterating a `RustVec<RustString>` yields `RustStringRef` (not `RustString`), which
2296///    only has `asStr()` but not `toString()` directly. swift-bridge auto-renames the
2297///    Rust `as_str` method to lowerCamelCase `asStr` on the Swift side.
2298/// 2. The accessor may end with an `Optional<RustVec<RustString>>` (e.g. `sheet_names()` is
2299///    `Option<Vec<String>>` in Rust, which becomes `Optional<RustVec<RustString>>` in Swift).
2300/// 3. Optional chaining from parent `?.` already produces `Optional<RustVec<T>>`.
2301///
2302/// The returned tuple's bool indicates whether the result is `Optional<[String]>`
2303/// (callers coalesce with `?? []`) or already a concrete `[String]`. Emitting
2304/// `?? []` against a non-optional value compiles with a Swift warning but is
2305/// surfaced as an error in strict CI configurations, so we only emit `?.map`
2306/// + `?? []` when the accessor is genuinely optional.
2307///
2308/// Generate a `XCTAssert{True|False}(array.contains(where: { elem_str.contains(val) }), msg)` line
2309/// for field paths that traverse a collection with `[].` notation (e.g. `links[].url`).
2310///
2311/// `array_part` — left side of `[].` (e.g. `"links"`)
2312/// `element_part` — right side (e.g. `"url"` or `"link_type"`)
2313/// `full_field` — original assertion.field (used for enum lookup against the full path)
2314#[allow(clippy::too_many_arguments)]
2315fn swift_traversal_contains_assert(
2316    array_part: &str,
2317    element_part: &str,
2318    full_field: &str,
2319    val_expr: &str,
2320    result_var: &str,
2321    negate: bool,
2322    msg: &str,
2323    enum_fields: &std::collections::HashSet<String>,
2324    field_resolver: &FieldResolver,
2325) -> String {
2326    let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
2327    let resolved_full = field_resolver.resolve(full_field);
2328    let resolved_elem_part = resolved_full
2329        .find("[].")
2330        .map(|d| &resolved_full[d + 3..])
2331        .unwrap_or(element_part);
2332    let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
2333    let elem_is_enum = enum_fields.contains(full_field) || enum_fields.contains(resolved_full);
2334    let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
2335        || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
2336    let elem_str = if elem_is_enum {
2337        // Enum-typed fields are bridged as `String` (RustString in Swift).
2338        // A single `.toString()` converts RustString → Swift String.
2339        format!("{elem_accessor}.toString()")
2340    } else if elem_is_optional {
2341        format!("({elem_accessor}?.toString() ?? \"\")")
2342    } else {
2343        format!("{elem_accessor}.toString()")
2344    };
2345    let assert_fn = if negate { "XCTAssertFalse" } else { "XCTAssertTrue" };
2346    format!("        {assert_fn}({array_accessor}.contains(where: {{ {elem_str}.contains({val_expr}) }}), \"{msg}\")")
2347}
2348
2349/// Returns `(map_expr, is_optional)` where `map_expr` is the `.map { … }` chain
2350/// that converts each element to a Swift `String`, and `is_optional` reports
2351/// whether the resulting expression is `Optional<[String]>` (callers should
2352/// coalesce with `?? []`) or already a concrete `[String]`.
2353fn swift_array_contains_expr(
2354    field: Option<&str>,
2355    result_var: &str,
2356    field_resolver: &FieldResolver,
2357    result_field_accessor: &HashMap<String, String>,
2358) -> (String, bool) {
2359    // swift-bridge auto-renames Rust snake_case methods to lowerCamelCase on the
2360    // Swift side. `RustStringRef::as_str()` is exposed as `asStr()` — emitting
2361    // `as_str()` produces "value of type 'XRef' has no member 'as_str'" at
2362    // compile time.
2363    let Some(f) = field else {
2364        return (format!("{result_var}.map {{ $0.asStr().toString() }}"), false);
2365    };
2366    // Allow per-call overrides to name a different element accessor — used when
2367    // the array element is an opaque struct whose "name string" accessor is
2368    // not `as_str` (e.g. `StructureItem` exposes `kind() -> String`). The map
2369    // is keyed on the fixture field name (and resolved alias as a fallback).
2370    let resolved_field = field_resolver.resolve(f);
2371    let elem_accessor_name = result_field_accessor
2372        .get(f)
2373        .or_else(|| result_field_accessor.get(resolved_field))
2374        .cloned()
2375        .unwrap_or_else(|| "as_str".to_string());
2376    let elem_call = swift_ident(&elem_accessor_name.to_lower_camel_case());
2377    let (accessor, has_optional) = swift_build_accessor(f, result_var, field_resolver);
2378    // Only chain `?.map` when the accessor is actually optional. The previous
2379    // unconditional `?.map` produced "cannot use optional chaining on
2380    // non-optional value of type 'RustVec<…>'" for plain `Vec<T>` fields.
2381    let field_is_optional =
2382        has_optional || field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f));
2383    if field_is_optional {
2384        (format!("{accessor}?.map {{ $0.{elem_call}().toString() }}"), true)
2385    } else {
2386        (format!("{accessor}.map {{ $0.{elem_call}().toString() }}"), false)
2387    }
2388}
2389
2390/// Emit a `XCTAssertTrue(array.contains(where: { ... }), msg)` line that
2391/// aggregates every text-bearing accessor on the element type of a `Vec<T>`
2392/// field, mirroring python's `_alef_e2e_item_texts` helper.
2393///
2394/// Returns `None` when:
2395///   - `field` is missing
2396///   - The field's root or leaf type cannot be resolved
2397///   - The element type has fewer than 2 stringy fields (the existing
2398///     single-accessor path is good enough and emits simpler code)
2399///
2400/// When matched, emits a closure that gathers `source().toString()`,
2401/// `items().map { $0.asStr().toString() }`, `alias()?.toString()`, etc. into
2402/// a flat `[String]` and substring-matches the expected value against every
2403/// entry. The matcher is lenient so that fixtures asserting `"os"` against
2404/// the `imports` field — where `ImportInfo.source` may be the bare module
2405/// name (`"os"`), the entire import statement (`"import os"`), or the
2406/// imported items (`from os import path` → items=["path"]) — succeed
2407/// regardless of how the language extractor surfaces the value.
2408fn swift_stringy_aggregator_contains_assert(
2409    field: Option<&str>,
2410    result_var: &str,
2411    field_resolver: &FieldResolver,
2412    swift_val: &str,
2413) -> Option<String> {
2414    use crate::field_access::StringyFieldKind;
2415    let field = field?;
2416    let resolved = field_resolver.resolve(field);
2417    // Only handle simple top-level array fields (no nested chains) for now.
2418    // Field path containing `.` or `[` is left to the existing traversal/array
2419    // paths.
2420    if resolved.contains('.') || resolved.contains('[') {
2421        return None;
2422    }
2423    let root_type = field_resolver.swift_root_type()?.clone();
2424    let elem_type = field_resolver.swift_advance(Some(&root_type), resolved)?;
2425    let stringy = field_resolver.swift_stringy_fields(&elem_type)?;
2426    if stringy.len() < 2 {
2427        return None;
2428    }
2429    let array_accessor = field_resolver.accessor(field, "swift", result_var);
2430    let mut texts_lines: Vec<String> = Vec::new();
2431    for sf in stringy {
2432        let call = swift_ident(&sf.name.to_lower_camel_case());
2433        match sf.kind {
2434            StringyFieldKind::Plain => {
2435                texts_lines.push(format!("                texts.append(item.{call}().toString())"));
2436            }
2437            StringyFieldKind::Optional => {
2438                texts_lines.push(format!(
2439                    "                if let v = item.{call}() {{ texts.append(v.toString()) }}"
2440                ));
2441            }
2442            StringyFieldKind::Vec => {
2443                // `item.field()` returns `RustVec<RustString>`. Mapping its
2444                // elements yields `RustStringRef` — a swift-bridge wrapper
2445                // around the borrowed RustString — which has `as_str()`
2446                // (snake_case, defined in `SwiftBridgeCore.swift`), NOT
2447                // `toString()` (only `RustString` has the latter via the
2448                // extension that calls `self.as_str().toString()`).
2449                texts_lines.push(format!(
2450                    "                texts.append(contentsOf: item.{call}().map {{ $0.as_str().toString() }})"
2451                ));
2452            }
2453        }
2454    }
2455    let texts_block = texts_lines.join("\n");
2456    Some(format!(
2457        "        XCTAssertTrue({array_accessor}.contains(where: {{ item in\n            var texts = [String]()\n{texts_block}\n            return texts.contains(where: {{ $0.contains({swift_val}) }})\n        }}), \"expected to contain: \\({swift_val})\")"
2458    ))
2459}
2460
2461/// Generate a `.count` expression for an array field that may be nested inside optional parents.
2462///
2463/// Swift-bridge exposes all Rust fields as methods with `()`. When ancestor segments are
2464/// optional, we use `?.` chaining. The final count is coalesced with `?? 0` when there
2465/// are optional ancestors so the XCTAssert macro receives a non-optional `Int`.
2466///
2467/// Also check if the field itself (the leaf) is optional, which happens when the field
2468/// returns Optional<RustVec<T>> (e.g., `links()` may return Optional).
2469fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
2470    let Some(f) = field else {
2471        return format!("{result_var}.count");
2472    };
2473    let (accessor, mut has_optional) = swift_build_accessor(f, result_var, field_resolver);
2474    // Also check if the leaf field itself is optional.
2475    if field_resolver.is_optional(f) {
2476        has_optional = true;
2477    }
2478    if has_optional {
2479        // In Swift, accessing .count on an optional with ?. returns Optional<Int>,
2480        // so we coalesce with ?? 0 to get a concrete Int for XCTAssert.
2481        if accessor.contains("?.") {
2482            format!("{accessor}.count ?? 0")
2483        } else {
2484            // If no ?. but field is optional, the field_expr itself is Optional<RustVec<T>>
2485            // so we need ?. to call count.
2486            format!("({accessor}?.count ?? 0)")
2487        }
2488    } else {
2489        format!("{accessor}.count")
2490    }
2491}
2492
2493/// Convert a `serde_json::Value` to a Swift literal string.
2494/// Returns true when `element_type` names a scalar Rust/Swift element type.
2495///
2496/// Scalar element types describe `Vec<T>` Rust parameters that the swift-bridge
2497/// surface exposes as native Swift `[T]` arrays — these can be constructed from
2498/// a Swift array literal without any opaque-type intermediate. Object element
2499/// types (everything else) require an `options_via` configuration to construct.
2500fn is_scalar_element_type(element_type: Option<&str>) -> bool {
2501    matches!(
2502        element_type.map(str::trim),
2503        Some(
2504            "String"
2505                | "str"
2506                | "bool"
2507                | "i8"
2508                | "i16"
2509                | "i32"
2510                | "i64"
2511                | "isize"
2512                | "u8"
2513                | "u16"
2514                | "u32"
2515                | "u64"
2516                | "usize"
2517                | "f32"
2518                | "f64",
2519        )
2520    )
2521}
2522
2523fn json_to_swift(value: &serde_json::Value) -> String {
2524    match value {
2525        serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
2526        serde_json::Value::Bool(b) => b.to_string(),
2527        serde_json::Value::Number(n) => n.to_string(),
2528        serde_json::Value::Null => "nil".to_string(),
2529        serde_json::Value::Array(arr) => {
2530            let items: Vec<String> = arr.iter().map(json_to_swift).collect();
2531            format!("[{}]", items.join(", "))
2532        }
2533        serde_json::Value::Object(_) => {
2534            let json_str = serde_json::to_string(value).unwrap_or_default();
2535            format!("\"{}\"", escape_swift(&json_str))
2536        }
2537    }
2538}
2539
2540/// When comparing numeric values in Swift, if the field expression uses method-call
2541/// syntax (contains `()` indicating opaque swift-bridge types), we need to consider
2542/// the numeric literal type. Integer literals may need wrapping in `UInt(...)` to
2543/// match opaque methods that return UInt (like `metrics().totalLines()`). However,
2544/// floating-point literals should NOT be wrapped, as they may compare against fields
2545/// that return `Double` (like `relevanceScore()`). Type inference should handle the
2546/// field type correctly based on the comparison operator and literal value.
2547fn swift_numeric_literal_cast(field_expr: &str, numeric_literal: &str) -> String {
2548    // Only wrap integer literals in UInt(...) for method-call expressions.
2549    // Don't wrap floats, as the field type is unknown and may return Double.
2550    if field_expr.contains("()") && !numeric_literal.contains('.') {
2551        format!("UInt({})", numeric_literal)
2552    } else {
2553        numeric_literal.to_string()
2554    }
2555}
2556
2557/// Escape a string for embedding in a Swift double-quoted string literal.
2558fn escape_swift(s: &str) -> String {
2559    escape_swift_str(s)
2560}
2561
2562/// Return the count-able target expression for `field_expr`.
2563///
2564/// For opaque method-call accessors (ending in `()` or `()?`), the returned
2565/// value depends on the field's IR kind:
2566///
2567/// - `Vec<T>` ⇒ `RustVec<T>`, which exposes `.count` directly. No wrap.
2568/// - `String` ⇒ `RustString`, which does NOT expose `.count`. Wrap with
2569///   `.toString()` so `.count` lands on Swift `String`.
2570///
2571/// First-class property accessors (no trailing parens) return Swift values
2572/// that already support `.count` directly.
2573///
2574/// The discriminator is the field's resolved leaf type, looked up against the
2575/// `SwiftFirstClassMap`'s vec field set when available. If the field is
2576/// unknown (None), fall back to the conservative wrap — RustString is the
2577/// dominant scalar-leaf case for top-level assertions.
2578fn swift_count_target(field_expr: &str, field_resolver: &FieldResolver, field: Option<&str>) -> String {
2579    let is_method_call = field_expr.trim_end().ends_with(')');
2580    if !is_method_call {
2581        return field_expr.to_string();
2582    }
2583    if let Some(f) = field
2584        && field_resolver.leaf_is_vec_via_swift_map(field_resolver.resolve(f))
2585    {
2586        return field_expr.to_string();
2587    }
2588    format!("{field_expr}.toString()")
2589}
2590
2591/// Resolve the IR type name backing this call's result.
2592///
2593/// Lookup order mirrors PHP's `derive_root_type` for `[crates.e2e.calls.*]`
2594/// configs: any of `c, csharp, java, kotlin, go, php` overrides may carry a
2595/// `result_type = "ChatCompletionResponse"` field. The first non-empty value
2596/// wins. These overrides are language-agnostic IR type names — they were
2597/// originally added for the C/C# backends and other backends piggy-back on them
2598/// because the IR names are shared across every binding.
2599///
2600/// Returns `None` when no override sets `result_type`; the renderer then falls
2601/// back to the workspace-default heuristic in `SwiftFirstClassMap` (which
2602/// defaults to property access — the right call for first-class result types
2603/// like `FileObject` but wrong for opaque types like `ChatCompletionResponse`).
2604fn swift_call_result_type(call_config: &alef_core::config::e2e::CallConfig) -> Option<String> {
2605    const LOOKUP_LANGS: &[&str] = &["c", "csharp", "java", "kotlin", "go", "php"];
2606    for lang in LOOKUP_LANGS {
2607        if let Some(o) = call_config.overrides.get(*lang)
2608            && let Some(rt) = o.result_type.as_deref()
2609            && !rt.is_empty()
2610        {
2611            return Some(rt.to_string());
2612        }
2613    }
2614    None
2615}
2616
2617/// Returns true when the field type would be emitted as a Swift primitive value
2618/// or a known first-class Codable struct/unit-enum, so it can appear on a
2619/// first-class Codable Swift struct without forcing the host type into a
2620/// typealias. Mirrors `first_class_field_supported` in alef-backend-swift.
2621///
2622/// Accepts:
2623/// - `Primitive` and `String`
2624/// - `Named(S)` when `S` is in `known_dto_names` (seeded with unit-serde enums and
2625///   grown via fixed-point iteration over candidate struct DTOs)
2626/// - `Vec<T>` and `Optional<T>` recursively
2627///
2628/// Rejects `Map`, `Path`, `Bytes`, `Duration`, `Char`, `Json`, and unknown
2629/// `Named(_)` references (the backend treats those as typealias-to-opaque).
2630fn swift_first_class_field_supported(ty: &alef_core::ir::TypeRef, known_dto_names: &HashSet<String>) -> bool {
2631    use alef_core::ir::TypeRef;
2632    match ty {
2633        TypeRef::Primitive(_) | TypeRef::String => true,
2634        TypeRef::Named(name) => known_dto_names.contains(name),
2635        TypeRef::Vec(inner) | TypeRef::Optional(inner) => swift_first_class_field_supported(inner, known_dto_names),
2636        _ => false,
2637    }
2638}
2639
2640/// Build the per-type Swift first-class/opaque classification map used by
2641/// `render_swift_with_first_class_map`.
2642///
2643/// A TypeDef is treated as first-class (Codable Swift struct → property access)
2644/// when it is not opaque, has serde derives, has at least one field, and every
2645/// binding field is supported by `swift_first_class_field_supported` against the
2646/// current first-class set. All other public types end up as typealiases to
2647/// opaque `RustBridge.X` classes whose fields are swift-bridge methods
2648/// (`.id()`, `.status()`).
2649///
2650/// Mirrors the fixed-point iteration in `alef-backend-swift::gen_bindings.rs`
2651/// (lines 100-130). Without the fixed point, a type like `TranscriptionResponse`
2652/// that holds `Option<Vec<TranscriptionSegment>>` would be wrongly classified
2653/// opaque, causing the renderer to emit `.text()` against a first-class struct
2654/// whose `text` is a `public let` property.
2655///
2656/// `field_types` records the next-type that each Named field traverses into,
2657/// so the renderer can advance its current-type cursor through nested
2658/// `data[0].id` style paths.
2659fn build_swift_first_class_map(
2660    type_defs: &[alef_core::ir::TypeDef],
2661    enum_defs: &[alef_core::ir::EnumDef],
2662    e2e_config: &crate::config::E2eConfig,
2663) -> SwiftFirstClassMap {
2664    use alef_core::ir::TypeRef;
2665    let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2666    let mut vec_field_names: HashSet<String> = HashSet::new();
2667    fn inner_named(ty: &TypeRef) -> Option<String> {
2668        match ty {
2669            TypeRef::Named(n) => Some(n.clone()),
2670            TypeRef::Optional(inner) | TypeRef::Vec(inner) => inner_named(inner),
2671            _ => None,
2672        }
2673    }
2674    fn is_vec_ty(ty: &TypeRef) -> bool {
2675        match ty {
2676            TypeRef::Vec(_) => true,
2677            TypeRef::Optional(inner) => is_vec_ty(inner),
2678            _ => false,
2679        }
2680    }
2681    // Seed with unit serde enum names — Codable on the Swift side and can appear
2682    // as leaf fields on struct DTOs (matches gen_bindings.rs unit_serde_enum_names).
2683    let mut known_dto_names: HashSet<String> = enum_defs
2684        .iter()
2685        .filter(|e| e.has_serde && e.variants.iter().all(|v| v.fields.is_empty()))
2686        .map(|e| e.name.clone())
2687        .collect();
2688
2689    // Candidate struct DTOs: non-opaque, has_serde, non-empty fields.
2690    // Trait types and binding-excluded types are skipped (matches backend semantics
2691    // — note backend further filters via `exclude_types`, which we don't have here,
2692    // but accepting a superset is safe: types not actually emitted simply never
2693    // appear in path-access chains).
2694    let candidates: Vec<&alef_core::ir::TypeDef> = type_defs
2695        .iter()
2696        .filter(|td| !td.is_trait && !td.is_opaque && td.has_serde && !td.fields.is_empty())
2697        .collect();
2698
2699    loop {
2700        let prev = known_dto_names.len();
2701        for td in &candidates {
2702            if known_dto_names.contains(&td.name) {
2703                continue;
2704            }
2705            let all_supported = td
2706                .fields
2707                .iter()
2708                .filter(|f| !f.binding_excluded)
2709                .all(|f| swift_first_class_field_supported(&f.ty, &known_dto_names));
2710            if all_supported {
2711                known_dto_names.insert(td.name.clone());
2712            }
2713        }
2714        if known_dto_names.len() == prev {
2715            break;
2716        }
2717    }
2718
2719    // The first-class set on SwiftFirstClassMap conceptually represents structs
2720    // accessed via property syntax. Unit enums never appear as the *owner* of a
2721    // chain segment (they are leaves), but including them is harmless since
2722    // `advance()` never returns them as a current_type for further traversal.
2723    let first_class_types: HashSet<String> = candidates
2724        .iter()
2725        .filter(|td| known_dto_names.contains(&td.name))
2726        .map(|td| td.name.clone())
2727        .collect();
2728
2729    use crate::field_access::{StringyField, StringyFieldKind};
2730    // Enums are bridged as `String` on the swift-bridge surface (the binding
2731    // emits `fn kind(&self) -> String` for `kind: SomeEnum`), so they must
2732    // also count as text-bearing accessors when aggregating contains-matchers.
2733    let enum_names: HashSet<&str> = enum_defs.iter().map(|e| e.name.as_str()).collect();
2734    let classify_stringy = |ty: &TypeRef, field_optional: bool| -> Option<StringyFieldKind> {
2735        match ty {
2736            TypeRef::String => Some(if field_optional {
2737                StringyFieldKind::Optional
2738            } else {
2739                StringyFieldKind::Plain
2740            }),
2741            TypeRef::Named(name) if enum_names.contains(name.as_str()) => Some(if field_optional {
2742                StringyFieldKind::Optional
2743            } else {
2744                StringyFieldKind::Plain
2745            }),
2746            TypeRef::Optional(inner) => match inner.as_ref() {
2747                TypeRef::String => Some(StringyFieldKind::Optional),
2748                TypeRef::Named(name) if enum_names.contains(name.as_str()) => Some(StringyFieldKind::Optional),
2749                _ => None,
2750            },
2751            TypeRef::Vec(inner) => match inner.as_ref() {
2752                TypeRef::String => Some(StringyFieldKind::Vec),
2753                TypeRef::Named(name) if enum_names.contains(name.as_str()) => Some(StringyFieldKind::Vec),
2754                _ => None,
2755            },
2756            _ => None,
2757        }
2758    };
2759    let mut stringy_fields_by_type: HashMap<String, Vec<StringyField>> = HashMap::new();
2760    for td in type_defs {
2761        let mut td_field_types: HashMap<String, String> = HashMap::new();
2762        let mut td_stringy: Vec<StringyField> = Vec::new();
2763        for f in &td.fields {
2764            if let Some(named) = inner_named(&f.ty) {
2765                td_field_types.insert(f.name.clone(), named);
2766            }
2767            if is_vec_ty(&f.ty) {
2768                vec_field_names.insert(f.name.clone());
2769            }
2770            if f.binding_excluded {
2771                continue;
2772            }
2773            if let Some(kind) = classify_stringy(&f.ty, f.optional) {
2774                td_stringy.push(StringyField {
2775                    name: f.name.clone(),
2776                    kind,
2777                });
2778            }
2779        }
2780        if !td_field_types.is_empty() {
2781            field_types.insert(td.name.clone(), td_field_types);
2782        }
2783        if !td_stringy.is_empty() {
2784            stringy_fields_by_type.insert(td.name.clone(), td_stringy);
2785        }
2786    }
2787    // Best-effort root-type detection: pick a unique TypeDef that contains all
2788    // `result_fields`. Falls back to `None` (renderer defaults to first-class
2789    // property syntax for unknown roots).
2790    let root_type = if e2e_config.result_fields.is_empty() {
2791        None
2792    } else {
2793        let matches: Vec<&alef_core::ir::TypeDef> = type_defs
2794            .iter()
2795            .filter(|td| {
2796                let names: HashSet<&str> = td.fields.iter().map(|f| f.name.as_str()).collect();
2797                e2e_config.result_fields.iter().all(|rf| names.contains(rf.as_str()))
2798            })
2799            .collect();
2800        if matches.len() == 1 {
2801            Some(matches[0].name.clone())
2802        } else {
2803            None
2804        }
2805    };
2806    SwiftFirstClassMap {
2807        first_class_types,
2808        field_types,
2809        vec_field_names,
2810        root_type,
2811        stringy_fields_by_type,
2812    }
2813}
2814
2815#[cfg(test)]
2816mod tests {
2817    use super::*;
2818    use crate::field_access::FieldResolver;
2819    use std::collections::{HashMap, HashSet};
2820
2821    fn make_resolver_tool_calls() -> FieldResolver {
2822        // Resolver for `choices[0].message.tool_calls[0].function.name`:
2823        //   - `choices` is a registered array field
2824        //   - `choices.message.tool_calls` is optional (Optional<RustVec<ToolCall>>)
2825        let mut optional = HashSet::new();
2826        optional.insert("choices.message.tool_calls".to_string());
2827        let mut arrays = HashSet::new();
2828        arrays.insert("choices".to_string());
2829        FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
2830    }
2831
2832    /// Regression: after the optional `[0]` subscript, the codegen must NOT
2833    /// append a trailing `?`. The Swift compiler sees `?[0]` as consuming the
2834    /// optional chain, yielding the non-optional element type, so a subsequent
2835    /// `?.member` would trigger "cannot use optional chaining on non-optional
2836    /// value".
2837    ///
2838    /// With no `SwiftFirstClassMap` configured (default in this test), every
2839    /// accessor is emitted as a swift-bridge method call — so accessors are
2840    /// `result.choices()[0].message().toolCalls()?[0].function().name()`.
2841    #[test]
2842    fn optional_vec_subscript_does_not_emit_trailing_question_mark_before_next_segment() {
2843        let resolver = make_resolver_tool_calls();
2844        let (accessor, has_optional) =
2845            swift_build_accessor("choices[0].message.tool_calls[0].function.name", "result", &resolver);
2846        // `?` before `[0]` is correct (tool_calls is optional). Method-call
2847        // syntax (with `()`) is the default when no SwiftFirstClassMap is
2848        // supplied.
2849        assert!(
2850            accessor.contains("toolCalls()?[0]"),
2851            "expected `toolCalls()?[0]` for optional tool_calls, got: {accessor}"
2852        );
2853        // There must NOT be `?[0]?` (trailing `?` after the index).
2854        assert!(
2855            !accessor.contains("?[0]?"),
2856            "must not emit trailing `?` after subscript index: {accessor}"
2857        );
2858        // The expression IS optional overall (tool_calls may be nil).
2859        assert!(has_optional, "expected has_optional=true for optional field chain");
2860        // Subsequent member access uses `.` (non-optional chain) not `?.`.
2861        assert!(
2862            accessor.contains("[0].function"),
2863            "expected `.function` (non-optional) after subscript: {accessor}"
2864        );
2865    }
2866
2867    /// `contains` against an array of opaque DTOs must aggregate every
2868    /// text-bearing accessor of the element type and substring-match the
2869    /// expected value, mirroring python's `_alef_e2e_item_texts`. This
2870    /// avoids the brittle "primary accessor" guess (e.g. ImportInfo →
2871    /// source) that misses values surfaced through sibling fields like
2872    /// `items` or `alias`.
2873    #[test]
2874    fn contains_against_vec_dto_aggregates_stringy_accessors() {
2875        use crate::field_access::{StringyField, StringyFieldKind, SwiftFirstClassMap};
2876        // Simulate the ImportInfo element type with its three text-bearing
2877        // accessors: source (plain), items (vec), alias (optional).
2878        let mut stringy_fields_by_type: HashMap<String, Vec<StringyField>> = HashMap::new();
2879        stringy_fields_by_type.insert(
2880            "ImportInfo".to_string(),
2881            vec![
2882                StringyField {
2883                    name: "source".to_string(),
2884                    kind: StringyFieldKind::Plain,
2885                },
2886                StringyField {
2887                    name: "items".to_string(),
2888                    kind: StringyFieldKind::Vec,
2889                },
2890                StringyField {
2891                    name: "alias".to_string(),
2892                    kind: StringyFieldKind::Optional,
2893                },
2894            ],
2895        );
2896        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2897        let mut process_fields = HashMap::new();
2898        process_fields.insert("imports".to_string(), "ImportInfo".to_string());
2899        field_types.insert("ProcessResult".to_string(), process_fields);
2900
2901        let mut arrays = HashSet::new();
2902        arrays.insert("imports".to_string());
2903
2904        let map = SwiftFirstClassMap {
2905            first_class_types: HashSet::new(),
2906            field_types,
2907            vec_field_names: HashSet::new(),
2908            root_type: None,
2909            stringy_fields_by_type,
2910        };
2911        let resolver = FieldResolver::new_with_swift_first_class(
2912            &HashMap::new(),
2913            &HashSet::new(),
2914            &HashSet::new(),
2915            &arrays,
2916            &HashSet::new(),
2917            &HashMap::new(),
2918            map,
2919        )
2920        .with_swift_root_type(Some("ProcessResult".to_string()));
2921
2922        let line = swift_stringy_aggregator_contains_assert(Some("imports"), "result", &resolver, "\"os\"")
2923            .expect("aggregator should fire for Vec<ImportInfo> contains");
2924        assert!(
2925            line.contains("result.imports().contains(where: { item in"),
2926            "expected contains(where:) over result.imports(): {line}"
2927        );
2928        assert!(
2929            line.contains("texts.append(item.source().toString())"),
2930            "expected plain source() accessor: {line}"
2931        );
2932        assert!(
2933            line.contains("texts.append(contentsOf: item.items().map { $0.as_str().toString() })"),
2934            "expected vec items() flattened via .map as_str(): {line}"
2935        );
2936        assert!(
2937            line.contains("if let v = item.alias()"),
2938            "expected optional alias() unwrap: {line}"
2939        );
2940        // Substring match — NOT exact equality.
2941        assert!(
2942            line.contains("$0.contains(\"os\")"),
2943            "expected substring contains over expected value: {line}"
2944        );
2945        assert!(!line.contains("$0 == \"os\""), "must not use exact equality: {line}");
2946    }
2947
2948    /// When the element type has fewer than 2 stringy accessors, the
2949    /// aggregator should bow out and let the simpler single-accessor path
2950    /// emit code — keeping diff churn minimal on fixtures that already pass.
2951    #[test]
2952    fn contains_aggregator_skips_when_only_one_stringy_field() {
2953        use crate::field_access::{StringyField, StringyFieldKind, SwiftFirstClassMap};
2954        let mut stringy_fields_by_type: HashMap<String, Vec<StringyField>> = HashMap::new();
2955        stringy_fields_by_type.insert(
2956            "TagInfo".to_string(),
2957            vec![StringyField {
2958                name: "name".to_string(),
2959                kind: StringyFieldKind::Plain,
2960            }],
2961        );
2962        let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2963        let mut root_fields = HashMap::new();
2964        root_fields.insert("tags".to_string(), "TagInfo".to_string());
2965        field_types.insert("Root".to_string(), root_fields);
2966        let mut arrays = HashSet::new();
2967        arrays.insert("tags".to_string());
2968        let map = SwiftFirstClassMap {
2969            first_class_types: HashSet::new(),
2970            field_types,
2971            vec_field_names: HashSet::new(),
2972            root_type: None,
2973            stringy_fields_by_type,
2974        };
2975        let resolver = FieldResolver::new_with_swift_first_class(
2976            &HashMap::new(),
2977            &HashSet::new(),
2978            &HashSet::new(),
2979            &arrays,
2980            &HashSet::new(),
2981            &HashMap::new(),
2982            map,
2983        )
2984        .with_swift_root_type(Some("Root".to_string()));
2985        assert!(
2986            swift_stringy_aggregator_contains_assert(Some("tags"), "result", &resolver, "\"x\"").is_none(),
2987            "single-stringy-field types must not trigger the aggregator"
2988        );
2989    }
2990}