Skip to main content

alef_e2e/codegen/
swift.rs

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