Skip to main content

alef_e2e/codegen/
swift.rs

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