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