Skip to main content

alef_e2e/codegen/
dart.rs

1//! Dart e2e test generator using package:test and package:http.
2//!
3//! Generates `e2e/dart/test/<category>_test.dart` files from JSON fixtures.
4//! HTTP fixtures hit the mock server at `MOCK_SERVER_URL/fixtures/<id>`.
5//! Non-HTTP fixtures without a dart-specific call override emit a skip stub.
6
7use crate::codegen::resolve_field;
8use crate::config::E2eConfig;
9use crate::escape::sanitize_filename;
10use crate::field_access::FieldResolver;
11use crate::fixture::{Assertion, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::hash::{self, CommentStyle};
15use alef_core::template_versions::pub_dev;
16use anyhow::Result;
17use heck::ToLowerCamelCase;
18use std::cell::Cell;
19use std::fmt::Write as FmtWrite;
20use std::path::PathBuf;
21
22use super::E2eCodegen;
23use super::client;
24
25/// Dart e2e code generator.
26pub struct DartE2eCodegen;
27
28impl E2eCodegen for DartE2eCodegen {
29    fn generate(
30        &self,
31        groups: &[FixtureGroup],
32        e2e_config: &E2eConfig,
33        config: &ResolvedCrateConfig,
34        _type_defs: &[alef_core::ir::TypeDef],
35    ) -> Result<Vec<GeneratedFile>> {
36        let lang = self.language_name();
37        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
38
39        let mut files = Vec::new();
40
41        // Resolve package config.
42        let dart_pkg = e2e_config.resolve_package("dart");
43        let pkg_name = dart_pkg
44            .as_ref()
45            .and_then(|p| p.name.as_ref())
46            .cloned()
47            .unwrap_or_else(|| config.dart_pubspec_name());
48        let pkg_path = dart_pkg
49            .as_ref()
50            .and_then(|p| p.path.as_ref())
51            .cloned()
52            .unwrap_or_else(|| "../../packages/dart".to_string());
53        let pkg_version = dart_pkg
54            .as_ref()
55            .and_then(|p| p.version.as_ref())
56            .cloned()
57            .or_else(|| config.resolved_version())
58            .unwrap_or_else(|| "0.1.0".to_string());
59
60        // Generate pubspec.yaml with http dependency for HTTP client tests.
61        files.push(GeneratedFile {
62            path: output_base.join("pubspec.yaml"),
63            content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
64            generated_header: false,
65        });
66
67        // Generate dart_test.yaml to limit parallelism — the mock server uses keep-alive
68        // connections and gets overwhelmed when test files run in parallel.
69        files.push(GeneratedFile {
70            path: output_base.join("dart_test.yaml"),
71            content: concat!(
72                "# Generated by alef — DO NOT EDIT.\n",
73                "# Run test files sequentially to avoid overwhelming the mock server with\n",
74                "# concurrent keep-alive connections.\n",
75                "concurrency: 1\n",
76            )
77            .to_string(),
78            generated_header: false,
79        });
80
81        let test_base = output_base.join("test");
82
83        // One test file per fixture group.
84        let bridge_class = config.dart_bridge_class_name();
85
86        // Methods declared as `stub_methods` in `[crates.dart]` cannot be bridged through
87        // FRB and have `unimplemented!()` bodies on the Rust side. Emitting e2e tests for
88        // these fixtures would result in `PanicException` at run-time. Filter them out
89        // here so the dart e2e suite mirrors the actual runtime surface of the binding.
90        let dart_stub_methods: std::collections::HashSet<String> = config
91            .dart
92            .as_ref()
93            .map(|d| d.stub_methods.iter().cloned().collect())
94            .unwrap_or_default();
95
96        for group in groups {
97            let active: Vec<&Fixture> = group
98                .fixtures
99                .iter()
100                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
101                .filter(|f| {
102                    let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
103                    let resolved_function = call_config
104                        .overrides
105                        .get(lang)
106                        .and_then(|o| o.function.as_ref())
107                        .cloned()
108                        .unwrap_or_else(|| call_config.function.clone());
109                    !dart_stub_methods.contains(&resolved_function)
110                })
111                .collect();
112
113            if active.is_empty() {
114                continue;
115            }
116
117            let filename = format!("{}_test.dart", sanitize_filename(&group.category));
118            let content = render_test_file(&group.category, &active, e2e_config, lang, &pkg_name, &bridge_class);
119            files.push(GeneratedFile {
120                path: test_base.join(filename),
121                content,
122                generated_header: true,
123            });
124        }
125
126        Ok(files)
127    }
128
129    fn language_name(&self) -> &'static str {
130        "dart"
131    }
132}
133
134// ---------------------------------------------------------------------------
135// Rendering
136// ---------------------------------------------------------------------------
137
138fn render_pubspec(
139    pkg_name: &str,
140    pkg_path: &str,
141    pkg_version: &str,
142    dep_mode: crate::config::DependencyMode,
143) -> String {
144    let test_ver = pub_dev::TEST_PACKAGE;
145    let http_ver = pub_dev::HTTP_PACKAGE;
146
147    let dep_block = match dep_mode {
148        crate::config::DependencyMode::Registry => {
149            format!("  {pkg_name}: ^{pkg_version}")
150        }
151        crate::config::DependencyMode::Local => {
152            format!("  {pkg_name}:\n    path: {pkg_path}")
153        }
154    };
155
156    let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
157    format!(
158        r#"name: e2e_dart
159version: 0.1.0
160publish_to: none
161
162environment:
163  sdk: "{sdk}"
164
165dependencies:
166{dep_block}
167
168dev_dependencies:
169  test: {test_ver}
170  http: {http_ver}
171"#
172    )
173}
174
175fn render_test_file(
176    category: &str,
177    fixtures: &[&Fixture],
178    e2e_config: &E2eConfig,
179    lang: &str,
180    pkg_name: &str,
181    bridge_class: &str,
182) -> String {
183    let mut out = String::new();
184    out.push_str(&hash::header(CommentStyle::DoubleSlash));
185
186    // Build the field resolver from the e2e config so assertion rendering can validate
187    // fixture field paths against the configured result type — assertions on fields that
188    // don't exist on the result type are emitted as `// skipped:` comments rather than
189    // compile-time errors. Mirrors the Python/Go/Java/TypeScript codegen pattern.
190    let field_resolver = FieldResolver::new(
191        &e2e_config.fields,
192        &e2e_config.fields_optional,
193        &e2e_config.result_fields,
194        &e2e_config.fields_array,
195        &e2e_config.fields_method_calls,
196    );
197
198    // Check if any fixture needs the http package (HTTP server tests).
199    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
200
201    // Check if any fixture needs Uint8List.fromList (batch item byte arrays).
202    let has_batch_byte_items = fixtures.iter().any(|f| {
203        let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
204        call_config.args.iter().any(|a| {
205            a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
206        })
207    });
208
209    // Detect whether any fixture uses file_path or bytes args — if so, setUpAll must chdir
210    // to the test_documents directory so that relative paths like "docx/fake.docx" resolve.
211    // Mirrors the Ruby/Python conftest and Swift setUp patterns.
212    let needs_chdir = fixtures.iter().any(|f| {
213        if f.is_http_test() {
214            return false;
215        }
216        let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
217        call_config
218            .args
219            .iter()
220            .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
221    });
222
223    // Detect whether any non-HTTP fixture uses a json_object arg that resolves to a JSON array —
224    // those are materialized via `jsonDecode` at test-run time and cast to `List<String>`.
225    // Handle args themselves no longer require `jsonDecode` since they construct the config via
226    // the FRB-generated `createCrawlConfigFromJson(json:)` helper which accepts the JSON string
227    // directly. The variable name is kept as `has_handle_args` for downstream stability.
228    let has_handle_args = fixtures.iter().any(|f| {
229        if f.is_http_test() {
230            return false;
231        }
232        let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
233        call_config
234            .args
235            .iter()
236            .any(|a| a.arg_type == "json_object" && super::resolve_field(&f.input, &a.field).is_array())
237    });
238
239    let _ = writeln!(out, "import 'package:test/test.dart';");
240    let _ = writeln!(out, "import 'dart:io';");
241    if has_batch_byte_items {
242        let _ = writeln!(out, "import 'dart:typed_data';");
243    }
244    let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
245    // RustLib is the flutter_rust_bridge entrypoint; must be initialized before any FRB call.
246    // It lives in the FRB-generated frb_generated.dart inside `{pkg_name}_bridge_generated/`.
247    let _ = writeln!(
248        out,
249        "import 'package:{pkg_name}/src/{pkg_name}_bridge_generated/frb_generated.dart' show RustLib;"
250    );
251    if has_http_fixtures {
252        let _ = writeln!(out, "import 'dart:async';");
253    }
254    // dart:convert provides jsonDecode for handle-arg engine construction and HTTP response parsing.
255    if has_http_fixtures || has_handle_args {
256        let _ = writeln!(out, "import 'dart:convert';");
257    }
258    let _ = writeln!(out);
259
260    // Emit file-level HTTP client and serialization mutex.
261    //
262    // The shared HttpClient reuses keep-alive connections to minimize TCP overhead.
263    // The mutex (_lock) ensures requests are serialized within the file so the
264    // connection pool is not exercised concurrently by dart:test's async runner.
265    //
266    // _withRetry wraps the entire request closure with one automatic retry on
267    // transient connection errors (keep-alive connections can be silently closed
268    // by the server just as the client tries to reuse them).
269    if has_http_fixtures {
270        let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
271        let _ = writeln!(out);
272        let _ = writeln!(out, "var _lock = Future<void>.value();");
273        let _ = writeln!(out);
274        let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
275        let _ = writeln!(out, "  final current = _lock;");
276        let _ = writeln!(out, "  final next = Completer<void>();");
277        let _ = writeln!(out, "  _lock = next.future;");
278        let _ = writeln!(out, "  try {{");
279        let _ = writeln!(out, "    await current;");
280        let _ = writeln!(out, "    return await fn();");
281        let _ = writeln!(out, "  }} finally {{");
282        let _ = writeln!(out, "    next.complete();");
283        let _ = writeln!(out, "  }}");
284        let _ = writeln!(out, "}}");
285        let _ = writeln!(out);
286        // The `fn` here should be the full request closure — on socket failure we
287        // recreate the HttpClient (drops old pooled connections) and retry once.
288        let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
289        let _ = writeln!(out, "  try {{");
290        let _ = writeln!(out, "    return await fn();");
291        let _ = writeln!(out, "  }} on SocketException {{");
292        let _ = writeln!(out, "    _httpClient.close(force: true);");
293        let _ = writeln!(out, "    _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
294        let _ = writeln!(out, "    return fn();");
295        let _ = writeln!(out, "  }} on HttpException {{");
296        let _ = writeln!(out, "    _httpClient.close(force: true);");
297        let _ = writeln!(out, "    _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
298        let _ = writeln!(out, "    return fn();");
299        let _ = writeln!(out, "  }}");
300        let _ = writeln!(out, "}}");
301        let _ = writeln!(out);
302    }
303
304    let _ = writeln!(out, "// E2e tests for category: {category}");
305    let _ = writeln!(out, "void main() {{");
306
307    // Emit setUpAll to initialize the flutter_rust_bridge before any test runs and,
308    // when fixtures load files by path, chdir to test_documents so that relative
309    // paths like "docx/fake.docx" resolve correctly.
310    //
311    // The test_documents directory lives two levels above e2e/dart/ (at the repo root).
312    // The FIXTURES_DIR environment variable can override this for CI environments.
313    let _ = writeln!(out, "  setUpAll(() async {{");
314    let _ = writeln!(out, "    await RustLib.init();");
315    if needs_chdir {
316        let test_docs_path = e2e_config.test_documents_relative_from(0);
317        let _ = writeln!(
318            out,
319            "    final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
320        );
321        let _ = writeln!(out, "    final _dir = Directory(_testDocs);");
322        let _ = writeln!(out, "    if (_dir.existsSync()) Directory.current = _dir;");
323    }
324    let _ = writeln!(out, "  }});");
325    let _ = writeln!(out);
326
327    // Close the shared client after all tests in this file complete.
328    if has_http_fixtures {
329        let _ = writeln!(out, "  tearDownAll(() => _httpClient.close());");
330        let _ = writeln!(out);
331    }
332
333    for fixture in fixtures {
334        render_test_case(&mut out, fixture, e2e_config, lang, bridge_class, &field_resolver);
335    }
336
337    let _ = writeln!(out, "}}");
338    out
339}
340
341fn render_test_case(
342    out: &mut String,
343    fixture: &Fixture,
344    e2e_config: &E2eConfig,
345    lang: &str,
346    bridge_class: &str,
347    field_resolver: &FieldResolver,
348) {
349    // HTTP fixtures: hit the mock server.
350    if let Some(http) = &fixture.http {
351        render_http_test_case(out, fixture, http);
352        return;
353    }
354
355    // Non-HTTP fixtures: render a call-based test using the resolved call config.
356    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
357    let call_overrides = call_config.overrides.get(lang);
358    let mut function_name = call_overrides
359        .and_then(|o| o.function.as_ref())
360        .cloned()
361        .unwrap_or_else(|| call_config.function.clone());
362    // Convert snake_case function names to camelCase for Dart conventions.
363    function_name = function_name
364        .split('_')
365        .enumerate()
366        .map(|(i, part)| {
367            if i == 0 {
368                part.to_string()
369            } else {
370                let mut chars = part.chars();
371                match chars.next() {
372                    None => String::new(),
373                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
374                }
375            }
376        })
377        .collect::<Vec<_>>()
378        .join("");
379    let result_var = &call_config.result_var;
380    let description = escape_dart(&fixture.description);
381    let fixture_id = &fixture.id;
382    // `is_async` retained for future use (e.g. non-FRB backends); unused with FRB since
383    // all wrappers return Future<T>.
384    let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
385
386    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
387    let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
388    // `result_is_simple = true` means the dart return is a scalar/bytes value
389    // (e.g. `Uint8List` for speech/file_content), not a struct. Field-based
390    // assertions like `audio.not_empty` collapse to whole-result checks so we
391    // don't emit `result.audio` against a `Uint8List` receiver.
392    let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple;
393
394    // Resolve options_type and options_via from per-fixture → per-call → default.
395    // These drive how `json_object` args are constructed:
396    //   options_via = "from_json" — call `createTypeNameFromJson(json: r'...')` bridge
397    //                               helper and pass the result as a named parameter `req:`.
398    //   All other values (or absent) — existing behaviour (batch arrays, config objects,
399    //   generic JSON arrays, or nothing).
400    let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
401    let options_via: &str = call_overrides
402        .and_then(|o| o.options_via.as_deref())
403        .unwrap_or("kwargs");
404
405    // Build argument list from fixture.input and call_config.args.
406    // Use `resolve_field` (respects the `field` path like "input.data") rather than
407    // looking up by `arg_def.name` directly — the name and the field key may differ.
408    //
409    // For `extract_file_sync` / `extract_file` fixtures that omit `mime_type`,
410    // derive the MIME from the path extension so `extractBytesSync`/`extractBytes`
411    // can be called (both require an explicit MIME type).
412    let file_path_for_mime: Option<&str> = call_config
413        .args
414        .iter()
415        .find(|a| a.arg_type == "file_path")
416        .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
417
418    // Detect whether this call converts a file_path arg to bytes at test-run time.
419    // Dart cannot pass OS-level file paths through the FRB bridge — the idiomatic API
420    // is always bytes. When a file_path arg is present (and no caller-supplied dart
421    // function override has already been applied), remap the function name:
422    //   extractFile      → extractBytes
423    //   extractFileSync  → extractBytesSync
424    let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
425    // Apply the remap only when no per-fixture dart override has already specified the
426    // function — if the fixture author set a dart-specific function name we trust it.
427    let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
428    if has_file_path_arg && !caller_supplied_override {
429        function_name = match function_name.as_str() {
430            "extractFile" => "extractBytes".to_string(),
431            "extractFileSync" => "extractBytesSync".to_string(),
432            other => other.to_string(),
433        };
434    }
435
436    // setup_lines holds per-test statements that must precede the main call:
437    // engine construction (handle args) and URL building (mock_url args).
438    let mut setup_lines: Vec<String> = Vec::new();
439    let mut args = Vec::new();
440
441    for arg_def in &call_config.args {
442        match arg_def.arg_type.as_str() {
443            "mock_url" => {
444                let name = arg_def.name.clone();
445                if fixture.has_host_root_route() {
446                    let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
447                    setup_lines.push(format!(
448                        r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
449                    ));
450                } else {
451                    setup_lines.push(format!(
452                        r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
453                    ));
454                }
455                args.push(name);
456                continue;
457            }
458            "handle" => {
459                let name = arg_def.name.clone();
460                let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
461                let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
462                // Derive the create-function name: "engine" → "createEngine".
463                let create_fn = {
464                    let mut chars = name.chars();
465                    let pascal = match chars.next() {
466                        None => String::new(),
467                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
468                    };
469                    format!("create{pascal}")
470                };
471                if config_value.is_null()
472                    || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
473                {
474                    setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}();"));
475                } else {
476                    let json_str = serde_json::to_string(&config_value).unwrap_or_default();
477                    let config_var = format!("{name}Config");
478                    // FRB-generated free function: `createCrawlConfigFromJson(json: '...')` — async,
479                    // deserializes the JSON into the mirror struct via the Rust `create_<type>_from_json`
480                    // helper emitted by the dart backend. This avoids relying on a Dart-side `fromJson`
481                    // constructor (FRB classes don't expose one).
482                    setup_lines.push(format!(
483                        "final {config_var} = await createCrawlConfigFromJson(json: r'{json_str}');"
484                    ));
485                    // Facade exposes `createEngine` with a named `config:` parameter — call it that way.
486                    setup_lines.push(format!(
487                        "final {name} = await {bridge_class}.{create_fn}(config: {config_var});"
488                    ));
489                }
490                args.push(name);
491                continue;
492            }
493            _ => {}
494        }
495
496        let arg_value = resolve_field(&fixture.input, &arg_def.field);
497        match arg_def.arg_type.as_str() {
498            "bytes" | "file_path" => {
499                // `bytes`: value is a file path string; load file contents at test-run time.
500                // `file_path`: also loaded as bytes for dart — extractBytes/extractBytesSync is
501                // the idiomatic Dart API since the Dart runtime cannot pass OS-level file paths
502                // through the FFI bridge.
503                if let serde_json::Value::String(file_path) = arg_value {
504                    args.push(format!("File('{}').readAsBytesSync()", file_path));
505                }
506            }
507            "string" => {
508                // The dart wrapper (`KreuzbergBridge`) emits required params as POSITIONAL
509                // and optional params as named (`{...}` block). Match that convention here:
510                // required string args are positional literals, optional string args are
511                // emitted as `paramName: 'value'` named arguments.
512                //
513                // Special case: when dart remaps `extractFile*` → `extractBytes*`
514                // (because dart cannot pass OS file paths through the FFI bridge), the
515                // underlying wrapper signature requires `mime_type` positionally even
516                // though the source IR marks it as optional for `extractFile*`. Force
517                // mime_type to positional in that case. The remap may have been performed
518                // either implicitly by this codegen (no caller override) or explicitly by
519                // a per-call dart override that sets `function = "extract_bytes"`. Detect
520                // by inspecting the resolved Dart function name — `extractBytes` always
521                // requires `mimeType` as a positional argument in the Dart wrapper.
522                let dart_param_name = snake_to_camel(&arg_def.name);
523                let mime_required_due_to_remap = has_file_path_arg
524                    && arg_def.name == "mime_type"
525                    && (function_name == "extractBytes" || function_name == "extractBytesSync");
526                let is_optional = arg_def.optional && !mime_required_due_to_remap;
527                match arg_value {
528                    serde_json::Value::String(s) => {
529                        let literal = format!("'{}'", escape_dart(s));
530                        if is_optional {
531                            args.push(format!("{dart_param_name}: {literal}"));
532                        } else {
533                            args.push(literal);
534                        }
535                    }
536                    serde_json::Value::Null
537                        if arg_def.optional
538                        // Optional string absent from fixture — try to infer MIME from path
539                        // when the arg name looks like a MIME-type parameter.
540                        && arg_def.name == "mime_type" =>
541                    {
542                        let inferred = file_path_for_mime
543                            .and_then(mime_from_extension)
544                            .unwrap_or("application/octet-stream");
545                        if is_optional {
546                            args.push(format!("{dart_param_name}: '{inferred}'"));
547                        } else {
548                            args.push(format!("'{inferred}'"));
549                        }
550                    }
551                    // Other optional strings with null value are omitted.
552                    _ => {}
553                }
554            }
555            "json_object" => {
556                // Handle batch item arrays (BatchBytesItem / BatchFileItem).
557                if let Some(elem_type) = &arg_def.element_type {
558                    if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
559                        let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
560                        args.push(dart_items);
561                    } else if elem_type == "String" && arg_value.is_array() {
562                        // Scalar string array (e.g. `texts: ["a", "b"]` for embed_texts).
563                        // The `KreuzbergBridge` facade declares these parameters as required
564                        // positional (e.g. `embedTexts(List<String> texts, EmbeddingConfig config)`),
565                        // so the list literal must be passed positionally — matching the
566                        // facade contract rather than the underlying FRB bridge's named-arg
567                        // convention.
568                        let items: Vec<String> = arg_value
569                            .as_array()
570                            .unwrap()
571                            .iter()
572                            .filter_map(|v| v.as_str())
573                            .map(|s| format!("'{}'", escape_dart(s)))
574                            .collect();
575                        args.push(format!("<String>[{}]", items.join(", ")));
576                    }
577                } else if options_via == "from_json" {
578                    // `from_json` path: construct a typed mirror-struct via the generated
579                    // `create<TypeName>FromJson(json: '...')` bridge helper, then pass it
580                    // as the named FRB parameter `req: _var`.
581                    //
582                    // The helper is generated by `emit_from_json_fn` in the dart bridge-crate
583                    // generator and made available as a top-level function via the exported
584                    // `liter_llm_bridge_generated/lib.dart`. The parameter name used in the
585                    // bridge method call is always `req:` for single-request-object methods
586                    // (derived from the Rust IR param name).
587                    if let Some(opts_type) = options_type {
588                        if !arg_value.is_null() {
589                            let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
590                            // Escape for Dart single-quoted string literal (handles embedded quotes,
591                            // backslashes, and interpolation markers).
592                            let escaped_json = escape_dart(&json_str);
593                            let var_name = format!("_{}", arg_def.name);
594                            let dart_fn = type_name_to_create_from_json_dart(opts_type);
595                            setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
596                            // FRB bridge method param name is `req` for all single-request methods.
597                            // Use `req:` as the named argument label.
598                            args.push(format!("req: {var_name}"));
599                        }
600                    }
601                } else if arg_def.name == "config" {
602                    if let serde_json::Value::Object(map) = &arg_value {
603                        if !map.is_empty() {
604                            // When the call override specifies a non-default `options_type`
605                            // (e.g. `EmbeddingConfig` for `embed_texts`), or the override map
606                            // contains a non-scalar field that the literal `ExtractionConfig`
607                            // constructor cannot express (e.g. `output_format: "markdown"` is
608                            // a tagged enum, not a plain string), fall back to the
609                            // FRB-generated `create<Type>FromJson(json: '...')` helper which
610                            // round-trips the JSON through serde and so preserves enum tags,
611                            // nested configs, and string-valued enum variants verbatim.
612                            let explicit_options =
613                                options_type.is_some_and(|t| t != "ExtractionConfig" && t != "FileExtractionConfig");
614                            let has_non_scalar = map.values().any(|v| {
615                                matches!(
616                                    v,
617                                    serde_json::Value::String(_)
618                                        | serde_json::Value::Object(_)
619                                        | serde_json::Value::Array(_)
620                                )
621                            });
622                            if explicit_options || has_non_scalar {
623                                let opts_type = options_type.unwrap_or("ExtractionConfig");
624                                let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
625                                let escaped_json = escape_dart(&json_str);
626                                let var_name = format!("_{}", arg_def.name);
627                                let dart_fn = type_name_to_create_from_json_dart(opts_type);
628                                setup_lines
629                                    .push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
630                                args.push(var_name);
631                            } else {
632                                // Fixture provides scalar-only overrides — build an
633                                // `ExtractionConfig` constructor literal with defaults,
634                                // overriding only the bool/int fields present in the
635                                // fixture JSON. Handles configs such as
636                                // {force_ocr:true, disable_ocr:true} that toggle error paths.
637                                args.push(emit_extraction_config_dart(map));
638                            }
639                        }
640                    }
641                    // If config is null/absent, the wrapper supplies the default ExtractionConfig.
642                } else if arg_value.is_array() {
643                    // Generic JSON array (e.g. batch_urls: ["/page1", "/page2"]).
644                    // Decode via jsonDecode and cast to List<String> at test-run time.
645                    let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
646                    let var_name = arg_def.name.clone();
647                    setup_lines.push(format!(
648                        "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
649                    ));
650                    args.push(var_name);
651                }
652            }
653            _ => {}
654        }
655    }
656
657    // Resolve client_factory: when set, tests create a client instance and call
658    // methods on it rather than using static bridge-class calls. This mirrors the
659    // go/python/zig pattern for stateful clients (e.g. liter-llm).
660    let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
661        e2e_config
662            .call
663            .overrides
664            .get(lang)
665            .and_then(|o| o.client_factory.as_deref())
666    });
667
668    // Convert factory name to camelCase (same rule as function_name above).
669    let client_factory_camel: Option<String> = client_factory.map(|f| {
670        f.split('_')
671            .enumerate()
672            .map(|(i, part)| {
673                if i == 0 {
674                    part.to_string()
675                } else {
676                    let mut chars = part.chars();
677                    match chars.next() {
678                        None => String::new(),
679                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
680                    }
681                }
682            })
683            .collect::<Vec<_>>()
684            .join("")
685    });
686
687    // All bridge methods return Future<T> because FRB v2 wraps every Rust
688    // function as async in Dart — even "sync" Rust functions. Always emit an async
689    // test body and await the call so the test framework waits for the future.
690    let _ = writeln!(out, "  test('{description}', () async {{");
691
692    let args_str = args.join(", ");
693    let receiver_class = call_overrides
694        .and_then(|o| o.class.as_ref())
695        .cloned()
696        .unwrap_or_else(|| bridge_class.to_string());
697
698    // When client_factory is set, determine the mock URL and emit client instantiation.
699    // The mock URL derivation follows the same has_host_root_route / plain-fixture split
700    // used by the mock_url arg handler above.
701    let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
702        let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
703        let mock_url_setup = if !has_mock_url {
704            // No explicit mock_url arg — derive the URL inline.
705            if fixture.has_host_root_route() {
706                let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
707                Some(format!(
708                    "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
709                ))
710            } else {
711                Some(format!(
712                    r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
713                ))
714            }
715        } else {
716            None
717        };
718        let url_expr = if has_mock_url {
719            // A mock_url arg was emitted into setup_lines already — reuse the variable name
720            // from the first mock_url arg definition so we don't duplicate the URL.
721            call_config
722                .args
723                .iter()
724                .find(|a| a.arg_type == "mock_url")
725                .map(|a| a.name.clone())
726                .unwrap_or_else(|| "_mockUrl".to_string())
727        } else {
728            "_mockUrl".to_string()
729        };
730        let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
731        let full_setup = if let Some(url_line) = mock_url_setup {
732            Some(format!("{url_line}\n    {create_line}"))
733        } else {
734            Some(create_line)
735        };
736        ("_client".to_string(), full_setup)
737    } else {
738        (receiver_class.clone(), None)
739    };
740
741    if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
742        // Wrap setup + call in an async lambda so any exception at any step is caught.
743        // flutter_rust_bridge 2.x decodes Rust errors as raw String values (not Exception
744        // subtypes), so throwsException will not match. Use throwsA(anything) instead.
745        let _ = writeln!(out, "    await expectLater(() async {{");
746        for line in &setup_lines {
747            let _ = writeln!(out, "      {line}");
748        }
749        if let Some(extra) = &extra_setup {
750            for line in extra.lines() {
751                let _ = writeln!(out, "      {line}");
752            }
753        }
754        if is_streaming {
755            let _ = writeln!(out, "      return {receiver}.{function_name}({args_str}).toList();");
756        } else {
757            let _ = writeln!(out, "      return {receiver}.{function_name}({args_str});");
758        }
759        let _ = writeln!(out, "    }}(), throwsA(anything));");
760    } else if expects_error {
761        // No setup lines, direct call — same throwsA(anything) rationale as above.
762        if let Some(extra) = &extra_setup {
763            for line in extra.lines() {
764                let _ = writeln!(out, "    {line}");
765            }
766        }
767        if is_streaming {
768            let _ = writeln!(
769                out,
770                "    await expectLater({receiver}.{function_name}({args_str}).toList(), throwsA(anything));"
771            );
772        } else {
773            let _ = writeln!(
774                out,
775                "    await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
776            );
777        }
778    } else {
779        for line in &setup_lines {
780            let _ = writeln!(out, "    {line}");
781        }
782        if let Some(extra) = &extra_setup {
783            for line in extra.lines() {
784                let _ = writeln!(out, "    {line}");
785            }
786        }
787        if is_streaming {
788            let _ = writeln!(
789                out,
790                "    final {result_var} = await {receiver}.{function_name}({args_str}).toList();"
791            );
792        } else {
793            let _ = writeln!(
794                out,
795                "    final {result_var} = await {receiver}.{function_name}({args_str});"
796            );
797        }
798        for assertion in &fixture.assertions {
799            if is_streaming {
800                render_streaming_assertion_dart(out, assertion, result_var);
801            } else {
802                render_assertion_dart(out, assertion, result_var, result_is_simple, field_resolver);
803            }
804        }
805    }
806
807    let _ = writeln!(out, "  }});");
808    let _ = writeln!(out);
809}
810
811fn dart_format_value(val: &serde_json::Value) -> String {
812    match val {
813        serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
814        serde_json::Value::Bool(b) => b.to_string(),
815        serde_json::Value::Number(n) => n.to_string(),
816        serde_json::Value::Null => "null".to_string(),
817        other => format!("'{}'", escape_dart(&other.to_string())),
818    }
819}
820
821/// Render a single fixture assertion as a Dart `package:test` `expect(...)` call.
822///
823/// Field paths are converted per-segment to camelCase (FRB v2 convention) using
824/// [`field_to_dart_accessor`].  All 24 fixture assertion types are handled.
825///
826/// Assertions on fixture fields that are not in the configured `result_fields` set
827/// are emitted as a `// skipped:` comment instead — the Dart binding may model a
828/// different result shape than the fixture asserts on (e.g. flat `ScrapeResult` vs.
829/// nested `result.browser.*`), and emitting unresolvable getters would break the
830/// whole file at compile time.
831fn render_assertion_dart(
832    out: &mut String,
833    assertion: &Assertion,
834    result_var: &str,
835    result_is_simple: bool,
836    field_resolver: &FieldResolver,
837) {
838    // Skip assertions on fields that don't exist on the dart result type. This must run
839    // BEFORE the array-traversal and standard accessor paths since both emit code that
840    // references the field — an unknown field path produces an `isn't defined` error.
841    if !result_is_simple {
842        if let Some(f) = assertion.field.as_deref() {
843            // Use the head segment (before any `[].`) for validation since `is_valid_for_result`
844            // only checks the first path component.
845            let head = f.split("[].").next().unwrap_or(f);
846            if !head.is_empty() && !field_resolver.is_valid_for_result(head) {
847                let _ = writeln!(out, "    // skipped: field '{f}' not available on dart result type");
848                return;
849            }
850        }
851    }
852
853    // Skip assertions that traverse a tagged-union variant boundary. FRB exposes
854    // tagged unions like `FormatMetadata` as sealed classes whose variants are
855    // accessed via pattern matching (`switch (m) { case FormatMetadata_Excel ... }`)
856    // — there is no `.excel?` getter, so the fixture path cannot be expressed as
857    // a simple chained accessor without language-specific pattern-matching codegen.
858    if let Some(f) = assertion.field.as_deref() {
859        if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
860            let _ = writeln!(
861                out,
862                "    // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Dart)"
863            );
864            return;
865        }
866    }
867
868    // Handle array traversal (e.g. "links[].link_type" → any() expression).
869    if let Some(f) = assertion.field.as_deref() {
870        if let Some(dot) = f.find("[].") {
871            // Apply the alias mapping to the full `xxx[].yyy` path first so renamed
872            // sub-fields (e.g. `assets[].category` → `assets[].asset_category`) resolve
873            // correctly. Split *after* resolving so both the array head and the element
874            // path reflect any alias rewrites.
875            let resolved_full = field_resolver.resolve(f);
876            let (array_part, elem_part) = match resolved_full.find("[].") {
877                Some(rdot) => (&resolved_full[..rdot], &resolved_full[rdot + 3..]),
878                // Resolver mapped the path away from `[].` form — fall back to the original
879                // split, since downstream code expects the array/elem structure.
880                None => (&f[..dot], &f[dot + 3..]),
881            };
882            let array_accessor = if array_part.is_empty() {
883                result_var.to_string()
884            } else {
885                field_resolver.accessor(array_part, "dart", result_var)
886            };
887            let elem_accessor = field_to_dart_accessor(elem_part);
888            match assertion.assertion_type.as_str() {
889                "contains" => {
890                    if let Some(expected) = &assertion.value {
891                        let dart_val = dart_format_value(expected);
892                        let _ = writeln!(
893                            out,
894                            "    expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
895                        );
896                    }
897                }
898                "contains_all" => {
899                    if let Some(values) = &assertion.values {
900                        for val in values {
901                            let dart_val = dart_format_value(val);
902                            let _ = writeln!(
903                                out,
904                                "    expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isTrue);"
905                            );
906                        }
907                    }
908                }
909                "not_contains" => {
910                    if let Some(expected) = &assertion.value {
911                        let dart_val = dart_format_value(expected);
912                        let _ = writeln!(
913                            out,
914                            "    expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
915                        );
916                    } else if let Some(values) = &assertion.values {
917                        for val in values {
918                            let dart_val = dart_format_value(val);
919                            let _ = writeln!(
920                                out,
921                                "    expect({array_accessor}.any((e) => e.{elem_accessor}.toString().contains({dart_val})), isFalse);"
922                            );
923                        }
924                    }
925                }
926                "not_empty" => {
927                    let _ = writeln!(
928                        out,
929                        "    expect({array_accessor}.any((e) => e.{elem_accessor}.toString().isNotEmpty), isTrue);"
930                    );
931                }
932                other => {
933                    let _ = writeln!(
934                        out,
935                        "    // skipped: unsupported traversal assertion '{other}' on '{f}'"
936                    );
937                }
938            }
939            return;
940        }
941    }
942
943    let field_accessor = if result_is_simple {
944        // Whole-result assertion path: the dart return is a scalar (e.g. a
945        // `Uint8List` for speech/file_content), so any `field` on the
946        // assertion resolves to the whole value rather than a sub-accessor.
947        result_var.to_string()
948    } else {
949        match assertion.field.as_deref() {
950            // Use the shared accessor builder (`FieldResolver::accessor`) — it applies the
951            // alias mapping (e.g. `robots.is_allowed` → `is_allowed`), expands array
952            // segments to `[0]` lookups, and injects `!` after optional intermediates so
953            // chained access compiles under sound null safety.
954            Some(f) if !f.is_empty() => field_resolver.accessor(f, "dart", result_var),
955            _ => result_var.to_string(),
956        }
957    };
958
959    let format_value = |val: &serde_json::Value| -> String { dart_format_value(val) };
960
961    match assertion.assertion_type.as_str() {
962        "equals" | "field_equals" => {
963            if let Some(expected) = &assertion.value {
964                let dart_val = format_value(expected);
965                let _ = writeln!(out, "    expect({field_accessor}, equals({dart_val}));");
966            } else {
967                let _ = writeln!(
968                    out,
969                    "    // skipped: '{}' assertion missing value",
970                    assertion.assertion_type
971                );
972            }
973        }
974        "not_equals" => {
975            if let Some(expected) = &assertion.value {
976                let dart_val = format_value(expected);
977                let _ = writeln!(out, "    expect({field_accessor}, isNot(equals({dart_val})));");
978            }
979        }
980        "contains" => {
981            if let Some(expected) = &assertion.value {
982                let dart_val = format_value(expected);
983                let _ = writeln!(out, "    expect({field_accessor}, contains({dart_val}));");
984            } else {
985                let _ = writeln!(out, "    // skipped: 'contains' assertion missing value");
986            }
987        }
988        "contains_all" => {
989            if let Some(values) = &assertion.values {
990                for val in values {
991                    let dart_val = format_value(val);
992                    let _ = writeln!(out, "    expect({field_accessor}, contains({dart_val}));");
993                }
994            }
995        }
996        "contains_any" => {
997            if let Some(values) = &assertion.values {
998                let checks: Vec<String> = values
999                    .iter()
1000                    .map(|v| {
1001                        let dart_val = format_value(v);
1002                        format!("{field_accessor}.contains({dart_val})")
1003                    })
1004                    .collect();
1005                let joined = checks.join(" || ");
1006                let _ = writeln!(out, "    expect({joined}, isTrue);");
1007            }
1008        }
1009        "not_contains" => {
1010            if let Some(expected) = &assertion.value {
1011                let dart_val = format_value(expected);
1012                let _ = writeln!(out, "    expect({field_accessor}, isNot(contains({dart_val})));");
1013            } else if let Some(values) = &assertion.values {
1014                for val in values {
1015                    let dart_val = format_value(val);
1016                    let _ = writeln!(out, "    expect({field_accessor}, isNot(contains({dart_val})));");
1017                }
1018            }
1019        }
1020        "not_empty" => {
1021            // `isNotEmpty` only applies to types with a `.isEmpty` getter (collections,
1022            // strings, maps). For struct-shaped fields (e.g. `document: DocumentStructure`)
1023            // we instead assert the value is non-null — those types have no notion of
1024            // "empty" and the fixture intent is "the field is present".
1025            let is_collection = assertion.field.as_deref().is_some_and(|f| {
1026                let resolved = field_resolver.resolve(f);
1027                field_resolver.is_array(f) || field_resolver.is_array(resolved)
1028            });
1029            if is_collection {
1030                let _ = writeln!(out, "    expect({field_accessor}, isNotEmpty);");
1031            } else {
1032                let _ = writeln!(out, "    expect({field_accessor}, isNotNull);");
1033            }
1034        }
1035        "is_empty" => {
1036            // FRB models `Option<String>` / `Option<Vec<T>>` as nullable in Dart. The `isEmpty`
1037            // matcher throws `NoSuchMethodError` on `null`. Accept `null` as semantically
1038            // empty by combining `isNull` with `isEmpty` via `anyOf`.
1039            let _ = writeln!(out, "    expect({field_accessor}, anyOf(isNull, isEmpty));");
1040        }
1041        "starts_with" => {
1042            if let Some(expected) = &assertion.value {
1043                let dart_val = format_value(expected);
1044                let _ = writeln!(out, "    expect({field_accessor}, startsWith({dart_val}));");
1045            }
1046        }
1047        "ends_with" => {
1048            if let Some(expected) = &assertion.value {
1049                let dart_val = format_value(expected);
1050                let _ = writeln!(out, "    expect({field_accessor}, endsWith({dart_val}));");
1051            }
1052        }
1053        "min_length" => {
1054            if let Some(val) = &assertion.value {
1055                if let Some(n) = val.as_u64() {
1056                    // Use `?.length ?? 0` so a null receiver (FRB v2 maps
1057                    // Rust `Option<Vec<T>>` to dart `List<T>?`) still yields
1058                    // an int for the matcher.
1059                    let _ = writeln!(
1060                        out,
1061                        "    expect({field_accessor}?.length ?? 0, greaterThanOrEqualTo({n}));"
1062                    );
1063                }
1064            }
1065        }
1066        "max_length" => {
1067            if let Some(val) = &assertion.value {
1068                if let Some(n) = val.as_u64() {
1069                    let _ = writeln!(
1070                        out,
1071                        "    expect({field_accessor}?.length ?? 0, lessThanOrEqualTo({n}));"
1072                    );
1073                }
1074            }
1075        }
1076        "count_equals" => {
1077            if let Some(val) = &assertion.value {
1078                if let Some(n) = val.as_u64() {
1079                    let _ = writeln!(out, "    expect({field_accessor}?.length ?? 0, equals({n}));");
1080                }
1081            }
1082        }
1083        "count_min" => {
1084            if let Some(val) = &assertion.value {
1085                if let Some(n) = val.as_u64() {
1086                    let _ = writeln!(
1087                        out,
1088                        "    expect({field_accessor}?.length ?? 0, greaterThanOrEqualTo({n}));"
1089                    );
1090                }
1091            }
1092        }
1093        "matches_regex" => {
1094            if let Some(expected) = &assertion.value {
1095                let dart_val = format_value(expected);
1096                let _ = writeln!(out, "    expect({field_accessor}, matches(RegExp({dart_val})));");
1097            }
1098        }
1099        "is_true" => {
1100            let _ = writeln!(out, "    expect({field_accessor}, isTrue);");
1101        }
1102        "is_false" => {
1103            let _ = writeln!(out, "    expect({field_accessor}, isFalse);");
1104        }
1105        "greater_than" => {
1106            if let Some(val) = &assertion.value {
1107                let dart_val = format_value(val);
1108                let _ = writeln!(out, "    expect({field_accessor}, greaterThan({dart_val}));");
1109            }
1110        }
1111        "less_than" => {
1112            if let Some(val) = &assertion.value {
1113                let dart_val = format_value(val);
1114                let _ = writeln!(out, "    expect({field_accessor}, lessThan({dart_val}));");
1115            }
1116        }
1117        "greater_than_or_equal" => {
1118            if let Some(val) = &assertion.value {
1119                let dart_val = format_value(val);
1120                let _ = writeln!(out, "    expect({field_accessor}, greaterThanOrEqualTo({dart_val}));");
1121            }
1122        }
1123        "less_than_or_equal" => {
1124            if let Some(val) = &assertion.value {
1125                let dart_val = format_value(val);
1126                let _ = writeln!(out, "    expect({field_accessor}, lessThanOrEqualTo({dart_val}));");
1127            }
1128        }
1129        "not_null" => {
1130            let _ = writeln!(out, "    expect({field_accessor}, isNotNull);");
1131        }
1132        "not_error" => {
1133            // No-op: a thrown error from `await` would have failed the test already.
1134        }
1135        "error" => {
1136            // Handled at the test method level via throwsA(anything).
1137        }
1138        "method_result" => {
1139            if let Some(method) = &assertion.method {
1140                let dart_method = method.to_lower_camel_case();
1141                let check = assertion.check.as_deref().unwrap_or("not_null");
1142                let method_call = format!("{field_accessor}.{dart_method}()");
1143                match check {
1144                    "equals" => {
1145                        if let Some(expected) = &assertion.value {
1146                            let dart_val = format_value(expected);
1147                            let _ = writeln!(out, "    expect({method_call}, equals({dart_val}));");
1148                        }
1149                    }
1150                    "is_true" => {
1151                        let _ = writeln!(out, "    expect({method_call}, isTrue);");
1152                    }
1153                    "is_false" => {
1154                        let _ = writeln!(out, "    expect({method_call}, isFalse);");
1155                    }
1156                    "greater_than_or_equal" => {
1157                        if let Some(val) = &assertion.value {
1158                            let dart_val = format_value(val);
1159                            let _ = writeln!(out, "    expect({method_call}, greaterThanOrEqualTo({dart_val}));");
1160                        }
1161                    }
1162                    "count_min" => {
1163                        if let Some(val) = &assertion.value {
1164                            if let Some(n) = val.as_u64() {
1165                                let _ = writeln!(out, "    expect({method_call}.length, greaterThanOrEqualTo({n}));");
1166                            }
1167                        }
1168                    }
1169                    _ => {
1170                        let _ = writeln!(out, "    expect({method_call}, isNotNull);");
1171                    }
1172                }
1173            }
1174        }
1175        other => {
1176            let _ = writeln!(out, "    // skipped: unknown assertion type '{other}'");
1177        }
1178    }
1179}
1180
1181/// Render a single fixture assertion for a streaming result.
1182///
1183/// `result_var` is the `List<T>` collected via `.toList()` on the stream.
1184/// Supports:
1185/// - `not_error`: no-op (a thrown error would already fail the test).
1186/// - `count_min` with `field = "chunks"`: assert `result_var.length >= value`.
1187/// - `equals` with `field = "stream_content"`: concatenate `delta.content` and compare.
1188///
1189/// Other assertion types are emitted as comments.
1190fn render_streaming_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
1191    match assertion.assertion_type.as_str() {
1192        "not_error" => {
1193            // No-op: a thrown error from `.toList()` would have failed the test already.
1194        }
1195        "count_min" if assertion.field.as_deref() == Some("chunks") => {
1196            if let Some(serde_json::Value::Number(n)) = &assertion.value {
1197                let _ = writeln!(out, "    expect({result_var}.length, greaterThanOrEqualTo({n}));");
1198            }
1199        }
1200        "equals" if assertion.field.as_deref() == Some("stream_content") => {
1201            if let Some(serde_json::Value::String(expected)) = &assertion.value {
1202                let escaped = escape_dart(expected);
1203                let _ = writeln!(
1204                    out,
1205                    "    final _content = {result_var}.map((c) => c.choices.firstOrNull?.delta.content ?? '').join();"
1206                );
1207                let _ = writeln!(out, "    expect(_content, equals('{escaped}'));");
1208            }
1209        }
1210        other => {
1211            let _ = writeln!(out, "    // skipped streaming assertion: '{other}'");
1212        }
1213    }
1214}
1215
1216/// Converts a snake_case JSON key to Dart camelCase.
1217fn snake_to_camel(s: &str) -> String {
1218    let mut result = String::with_capacity(s.len());
1219    let mut next_upper = false;
1220    for ch in s.chars() {
1221        if ch == '_' {
1222            next_upper = true;
1223        } else if next_upper {
1224            result.extend(ch.to_uppercase());
1225            next_upper = false;
1226        } else {
1227            result.push(ch);
1228        }
1229    }
1230    result
1231}
1232
1233/// Convert a dot-separated fixture field path to a Dart accessor expression.
1234///
1235/// Each segment is converted to camelCase (FRB v2 convention); array-index brackets
1236/// (e.g. `choices[0]`) and map-key brackets (e.g. `tags[name]`) are preserved.
1237/// This replaces the former single-pass `snake_to_camel` call which incorrectly
1238/// treated the entire path string as one identifier.
1239///
1240/// Examples:
1241/// - `"choices"` → `"choices"`
1242/// - `"choices[0].message.content"` → `"choices[0].message.content"`
1243/// - `"metadata.document_title"` → `"metadata.documentTitle"`
1244/// - `"model_id"` → `"modelId"`
1245fn field_to_dart_accessor(path: &str) -> String {
1246    let mut result = String::with_capacity(path.len());
1247    for (i, segment) in path.split('.').enumerate() {
1248        if i > 0 {
1249            result.push('.');
1250        }
1251        // Separate a trailing `[...]` bracket from the field name so we only
1252        // camelCase the identifier part, not the bracket content. The owning
1253        // collection may be `List<T>?` when the underlying Rust field is
1254        // `Option<Vec<T>>`; force-unwrap with `!` so the `[N]` lookup and any
1255        // subsequent member access compile under sound null safety.
1256        if let Some(bracket_pos) = segment.find('[') {
1257            let name = &segment[..bracket_pos];
1258            let bracket = &segment[bracket_pos..];
1259            result.push_str(&name.to_lower_camel_case());
1260            result.push('!');
1261            result.push_str(bracket);
1262        } else {
1263            result.push_str(&segment.to_lower_camel_case());
1264        }
1265    }
1266    result
1267}
1268
1269/// Emits a Dart `ExtractionConfig(...)` constructor with default values, overriding
1270/// fields present in `overrides` (from fixture JSON, snake_case keys).
1271///
1272/// Only simple scalar overrides (bool, int) are supported. Complex nested types
1273/// (ocr, chunking, etc.) are left at their defaults (null).
1274fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
1275    // Collect scalar overrides; convert keys to camelCase.
1276    let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1277    for (key, val) in overrides {
1278        let camel = snake_to_camel(key);
1279        let dart_val = match val {
1280            serde_json::Value::Bool(b) => {
1281                if *b {
1282                    "true".to_string()
1283                } else {
1284                    "false".to_string()
1285                }
1286            }
1287            serde_json::Value::Number(n) => n.to_string(),
1288            serde_json::Value::String(s) => format!("'{s}'"),
1289            _ => continue, // skip complex nested objects
1290        };
1291        field_overrides.insert(camel, dart_val);
1292    }
1293
1294    let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
1295    let enable_quality_processing = field_overrides
1296        .remove("enableQualityProcessing")
1297        .unwrap_or_else(|| "true".to_string());
1298    let force_ocr = field_overrides
1299        .remove("forceOcr")
1300        .unwrap_or_else(|| "false".to_string());
1301    let disable_ocr = field_overrides
1302        .remove("disableOcr")
1303        .unwrap_or_else(|| "false".to_string());
1304    let include_document_structure = field_overrides
1305        .remove("includeDocumentStructure")
1306        .unwrap_or_else(|| "false".to_string());
1307    let use_layout_for_markdown = field_overrides
1308        .remove("useLayoutForMarkdown")
1309        .unwrap_or_else(|| "false".to_string());
1310    let max_archive_depth = field_overrides
1311        .remove("maxArchiveDepth")
1312        .unwrap_or_else(|| "3".to_string());
1313
1314    format!(
1315        "ExtractionConfig(useCache: {use_cache}, enableQualityProcessing: {enable_quality_processing}, forceOcr: {force_ocr}, disableOcr: {disable_ocr}, resultFormat: ResultFormat.unified, outputFormat: OutputFormat.plain(), includeDocumentStructure: {include_document_structure}, useLayoutForMarkdown: {use_layout_for_markdown}, maxArchiveDepth: {max_archive_depth})"
1316    )
1317}
1318
1319// ---------------------------------------------------------------------------
1320// HTTP server test rendering — DartTestClientRenderer impl + thin driver wrapper
1321// ---------------------------------------------------------------------------
1322
1323/// Renderer that emits `package:test` `test(...)` blocks using `dart:io HttpClient`
1324/// against the mock server (`Platform.environment['MOCK_SERVER_URL']`).
1325///
1326/// Skipped tests are emitted as self-contained stubs (complete test block with
1327/// `markTestSkipped`) entirely inside `render_test_open`. `render_test_close` uses
1328/// `in_skip` to emit the right closing token: nothing extra for skip stubs (already
1329/// closed) vs. `})));` for regular tests.
1330///
1331/// `is_redirect` must be set to `true` before invoking the shared driver for 3xx
1332/// fixtures so that `render_call` can inject `ioReq.followRedirects = false` after
1333/// the `openUrl` call.
1334struct DartTestClientRenderer {
1335    /// Set to `true` when `render_test_open` is called with a skip reason so that
1336    /// `render_test_close` can match the opening shape.
1337    in_skip: Cell<bool>,
1338    /// Pre-set to `true` by the thin wrapper when the fixture expects a 3xx response.
1339    /// `render_call` injects `ioReq.followRedirects = false` when this is `true`.
1340    is_redirect: Cell<bool>,
1341}
1342
1343impl DartTestClientRenderer {
1344    fn new(is_redirect: bool) -> Self {
1345        Self {
1346            in_skip: Cell::new(false),
1347            is_redirect: Cell::new(is_redirect),
1348        }
1349    }
1350}
1351
1352impl client::TestClientRenderer for DartTestClientRenderer {
1353    fn language_name(&self) -> &'static str {
1354        "dart"
1355    }
1356
1357    /// Emit the test opening.
1358    ///
1359    /// For skipped fixtures: emit the entire self-contained stub (open + body +
1360    /// close + blank line) and set `in_skip = true` so `render_test_close` is a
1361    /// no-op.
1362    ///
1363    /// For active fixtures: emit `test('desc', () => _serialized(() => _withRetry(() async {`
1364    /// leaving the block open for the assertion primitives.
1365    fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
1366        let escaped_desc = escape_dart(description);
1367        if let Some(reason) = skip_reason {
1368            let escaped_reason = escape_dart(reason);
1369            let _ = writeln!(out, "  test('{escaped_desc}', () {{");
1370            let _ = writeln!(out, "    markTestSkipped('{escaped_reason}');");
1371            let _ = writeln!(out, "  }});");
1372            let _ = writeln!(out);
1373            self.in_skip.set(true);
1374        } else {
1375            let _ = writeln!(
1376                out,
1377                "  test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
1378            );
1379            self.in_skip.set(false);
1380        }
1381    }
1382
1383    /// Emit the test closing token.
1384    ///
1385    /// No-op for skip stubs (the stub was fully closed in `render_test_open`).
1386    /// Emits `})));` followed by a blank line for regular tests.
1387    fn render_test_close(&self, out: &mut String) {
1388        if self.in_skip.get() {
1389            // Stub was already closed in render_test_open.
1390            return;
1391        }
1392        let _ = writeln!(out, "  }})));");
1393        let _ = writeln!(out);
1394    }
1395
1396    /// Emit the full `dart:io HttpClient` request scaffolding.
1397    ///
1398    /// Emits:
1399    /// - URL construction from `MOCK_SERVER_URL`.
1400    /// - `_httpClient.openUrl(method, uri)`.
1401    /// - `followRedirects = false` when `is_redirect` is pre-set on the renderer.
1402    /// - Content-Type header, request headers, cookies, optional body bytes.
1403    /// - `ioReq.close()` → `ioResp`.
1404    /// - Response-body drain into `bodyStr` (skipped for redirect responses).
1405    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
1406        // dart:io restricted headers (handled automatically by the HTTP stack).
1407        const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
1408
1409        let method = ctx.method.to_uppercase();
1410        let escaped_method = escape_dart(&method);
1411
1412        // Fixture path is `/fixtures/<id>` — extract the id portion for URL construction.
1413        let fixture_path = escape_dart(ctx.path);
1414
1415        // Determine effective content-type.
1416        let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
1417        let effective_content_type = if has_explicit_content_type {
1418            ctx.headers
1419                .iter()
1420                .find(|(k, _)| k.to_lowercase() == "content-type")
1421                .map(|(_, v)| v.as_str())
1422                .unwrap_or("application/json")
1423        } else if ctx.body.is_some() {
1424            ctx.content_type.unwrap_or("application/json")
1425        } else {
1426            ""
1427        };
1428
1429        let _ = writeln!(
1430            out,
1431            "    final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
1432        );
1433        let _ = writeln!(out, "    final uri = Uri.parse('$baseUrl{fixture_path}');");
1434        let _ = writeln!(
1435            out,
1436            "    final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
1437        );
1438
1439        // Disable automatic redirect following for 3xx fixtures so the test can
1440        // assert on the redirect status code itself.
1441        if self.is_redirect.get() {
1442            let _ = writeln!(out, "    ioReq.followRedirects = false;");
1443        }
1444
1445        // Set content-type header.
1446        if !effective_content_type.is_empty() {
1447            let escaped_ct = escape_dart(effective_content_type);
1448            let _ = writeln!(out, "    ioReq.headers.set('content-type', '{escaped_ct}');");
1449        }
1450
1451        // Set request headers (skip dart:io restricted headers and content-type, already handled).
1452        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
1453        header_pairs.sort_by_key(|(k, _)| k.as_str());
1454        for (name, value) in &header_pairs {
1455            if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
1456                continue;
1457            }
1458            if name.to_lowercase() == "content-type" {
1459                continue; // Already handled above.
1460            }
1461            let escaped_name = escape_dart(&name.to_lowercase());
1462            let escaped_value = escape_dart(value);
1463            let _ = writeln!(out, "    ioReq.headers.set('{escaped_name}', '{escaped_value}');");
1464        }
1465
1466        // Add cookies.
1467        if !ctx.cookies.is_empty() {
1468            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
1469            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
1470            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
1471            let cookie_header = escape_dart(&cookie_str.join("; "));
1472            let _ = writeln!(out, "    ioReq.headers.set('cookie', '{cookie_header}');");
1473        }
1474
1475        // Write body bytes if present (bypass charset-based encoding issues).
1476        if let Some(body) = ctx.body {
1477            let json_str = serde_json::to_string(body).unwrap_or_default();
1478            let escaped = escape_dart(&json_str);
1479            let _ = writeln!(out, "    final bodyBytes = utf8.encode('{escaped}');");
1480            let _ = writeln!(out, "    ioReq.add(bodyBytes);");
1481        }
1482
1483        let _ = writeln!(out, "    final ioResp = await ioReq.close();");
1484        // Drain the response body to bind `bodyStr` for assertion primitives and to
1485        // allow the server to cleanly close the connection (prevents RST packets).
1486        // Redirect responses have no body to drain — skip to avoid a potential hang.
1487        if !self.is_redirect.get() {
1488            let _ = writeln!(out, "    final bodyStr = await ioResp.transform(utf8.decoder).join();");
1489        };
1490    }
1491
1492    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
1493        let _ = writeln!(
1494            out,
1495            "    expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
1496        );
1497    }
1498
1499    /// Emit a single header assertion, handling special tokens `<<present>>`,
1500    /// `<<absent>>`, and `<<uuid>>`.
1501    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
1502        let escaped_name = escape_dart(&name.to_lowercase());
1503        match expected {
1504            "<<present>>" => {
1505                let _ = writeln!(
1506                    out,
1507                    "    expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
1508                );
1509            }
1510            "<<absent>>" => {
1511                let _ = writeln!(
1512                    out,
1513                    "    expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
1514                );
1515            }
1516            "<<uuid>>" => {
1517                let _ = writeln!(
1518                    out,
1519                    "    expect(ioResp.headers.value('{escaped_name}'), matches(RegExp(r'^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$')), reason: 'header {escaped_name} should be a UUID');"
1520                );
1521            }
1522            exact => {
1523                let escaped_value = escape_dart(exact);
1524                let _ = writeln!(
1525                    out,
1526                    "    expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
1527                );
1528            }
1529        }
1530    }
1531
1532    /// Emit an exact-equality body assertion.
1533    ///
1534    /// String bodies are compared as decoded text; structured JSON bodies are
1535    /// compared via `jsonDecode`.
1536    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1537        match expected {
1538            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1539                let json_str = serde_json::to_string(expected).unwrap_or_default();
1540                let escaped = escape_dart(&json_str);
1541                let _ = writeln!(out, "    final bodyJson = jsonDecode(bodyStr);");
1542                let _ = writeln!(out, "    final expectedJson = jsonDecode('{escaped}');");
1543                let _ = writeln!(
1544                    out,
1545                    "    expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1546                );
1547            }
1548            serde_json::Value::String(s) => {
1549                let escaped = escape_dart(s);
1550                let _ = writeln!(
1551                    out,
1552                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1553                );
1554            }
1555            other => {
1556                let escaped = escape_dart(&other.to_string());
1557                let _ = writeln!(
1558                    out,
1559                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1560                );
1561            }
1562        }
1563    }
1564
1565    /// Emit partial-body assertions — every key in `expected` must match the
1566    /// corresponding field in the parsed JSON response.
1567    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1568        let _ = writeln!(
1569            out,
1570            "    final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1571        );
1572        if let Some(obj) = expected.as_object() {
1573            for (idx, (key, val)) in obj.iter().enumerate() {
1574                let escaped_key = escape_dart(key);
1575                let json_val = serde_json::to_string(val).unwrap_or_default();
1576                let escaped_val = escape_dart(&json_val);
1577                // Use an index-based variable name so keys with special characters
1578                // don't produce invalid Dart identifiers.
1579                let _ = writeln!(out, "    final _expectedField{idx} = jsonDecode('{escaped_val}');");
1580                let _ = writeln!(
1581                    out,
1582                    "    expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1583                );
1584            }
1585        }
1586    }
1587
1588    /// Emit validation-error assertions for 422 responses.
1589    fn render_assert_validation_errors(
1590        &self,
1591        out: &mut String,
1592        _response_var: &str,
1593        errors: &[ValidationErrorExpectation],
1594    ) {
1595        let _ = writeln!(out, "    final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1596        let _ = writeln!(out, "    final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1597        for ve in errors {
1598            let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1599            let loc_str = loc_dart.join(", ");
1600            let escaped_msg = escape_dart(&ve.msg);
1601            let _ = writeln!(
1602                out,
1603                "    expect(errList.any((e) => e is Map && (e['loc'] as List?)?.join(',') == [{loc_str}].join(',') && (e['msg'] as String? ?? '').contains('{escaped_msg}')), isTrue, reason: 'validation error not found: {escaped_msg}');"
1604            );
1605        }
1606    }
1607}
1608
1609/// Render a `package:test` `test(...)` block for an HTTP server fixture.
1610///
1611/// Delegates to the shared [`client::http_call::render_http_test`] driver via
1612/// [`DartTestClientRenderer`]. HTTP 101 (WebSocket upgrade) fixtures are emitted
1613/// as skip stubs before reaching the driver because `dart:io HttpClient` cannot
1614/// handle protocol-switch responses.
1615fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1616    // HTTP 101 (WebSocket upgrade) — dart:io HttpClient cannot handle upgrade responses.
1617    if http.expected_response.status_code == 101 {
1618        let description = escape_dart(&fixture.description);
1619        let _ = writeln!(out, "  test('{description}', () {{");
1620        let _ = writeln!(
1621            out,
1622            "    markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1623        );
1624        let _ = writeln!(out, "  }});");
1625        let _ = writeln!(out);
1626        return;
1627    }
1628
1629    // Pre-set `is_redirect` on the renderer so `render_call` can inject
1630    // `ioReq.followRedirects = false` for 3xx fixtures. The shared driver has no
1631    // concept of expected status code so we thread it through renderer state.
1632    let is_redirect = http.expected_response.status_code / 100 == 3;
1633    client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1634}
1635
1636/// Infer a MIME type from a file path extension.
1637///
1638/// Returns `None` when the extension is unknown so the caller can supply a fallback.
1639/// Used in dart e2e tests when a fixture omits `mime_type` but uses a `file_path` arg.
1640fn mime_from_extension(path: &str) -> Option<&'static str> {
1641    let ext = path.rsplit('.').next()?;
1642    match ext.to_lowercase().as_str() {
1643        "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1644        "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1645        "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1646        "pdf" => Some("application/pdf"),
1647        "txt" | "text" => Some("text/plain"),
1648        "html" | "htm" => Some("text/html"),
1649        "json" => Some("application/json"),
1650        "xml" => Some("application/xml"),
1651        "csv" => Some("text/csv"),
1652        "md" | "markdown" => Some("text/markdown"),
1653        "png" => Some("image/png"),
1654        "jpg" | "jpeg" => Some("image/jpeg"),
1655        "gif" => Some("image/gif"),
1656        "zip" => Some("application/zip"),
1657        "odt" => Some("application/vnd.oasis.opendocument.text"),
1658        "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1659        "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1660        "rtf" => Some("application/rtf"),
1661        "epub" => Some("application/epub+zip"),
1662        "msg" => Some("application/vnd.ms-outlook"),
1663        "eml" => Some("message/rfc822"),
1664        _ => None,
1665    }
1666}
1667
1668/// Emit Dart constructors for a batch item array (`BatchBytesItem` or `BatchFileItem`).
1669///
1670/// Returns a Dart list literal like:
1671/// ```dart
1672/// [BatchBytesItem(content: Uint8List.fromList([72, 101, ...]), mimeType: 'text/plain')]
1673/// ```
1674fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1675    let items: Vec<String> = arr
1676        .as_array()
1677        .map(|a| a.as_slice())
1678        .unwrap_or_default()
1679        .iter()
1680        .filter_map(|item| {
1681            let obj = item.as_object()?;
1682            match elem_type {
1683                "BatchBytesItem" => {
1684                    let content_bytes = obj
1685                        .get("content")
1686                        .and_then(|v| v.as_array())
1687                        .map(|arr| {
1688                            let nums: Vec<String> =
1689                                arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1690                            format!("Uint8List.fromList([{}])", nums.join(", "))
1691                        })
1692                        .unwrap_or_else(|| "Uint8List(0)".to_string());
1693                    let mime_type = obj
1694                        .get("mime_type")
1695                        .and_then(|v| v.as_str())
1696                        .unwrap_or("application/octet-stream");
1697                    Some(format!(
1698                        "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1699                        escape_dart(mime_type)
1700                    ))
1701                }
1702                "BatchFileItem" => {
1703                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1704                    Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
1705                }
1706                _ => None,
1707            }
1708        })
1709        .collect();
1710    format!("[{}]", items.join(", "))
1711}
1712
1713/// Escape a string for embedding in a Dart single-quoted string literal.
1714fn escape_dart(s: &str) -> String {
1715    s.replace('\\', "\\\\")
1716        .replace('\'', "\\'")
1717        .replace('\n', "\\n")
1718        .replace('\r', "\\r")
1719        .replace('\t', "\\t")
1720        .replace('$', "\\$")
1721}
1722
1723/// Derive the Dart top-level helper function name for constructing a mirror type from JSON.
1724///
1725/// The alef dart bridge-crate generator emits a Rust free function
1726/// `create_<snake_type>_from_json(json: String)` for each non-opaque mirror struct.
1727/// FRB generates the corresponding Dart function as `createTypeNameFromJson` (camelCase).
1728///
1729/// Example: `"ChatCompletionRequest"` → `"createChatCompletionRequestFromJson"`.
1730fn type_name_to_create_from_json_dart(type_name: &str) -> String {
1731    // Convert PascalCase type name to snake_case.
1732    let mut snake = String::with_capacity(type_name.len() + 8);
1733    for (i, ch) in type_name.char_indices() {
1734        if ch.is_uppercase() {
1735            if i > 0 {
1736                snake.push('_');
1737            }
1738            snake.extend(ch.to_lowercase());
1739        } else {
1740            snake.push(ch);
1741        }
1742    }
1743    // snake is now e.g. "chat_completion_request"
1744    // Full Rust function name: "create_chat_completion_request_from_json"
1745    let rust_fn = format!("create_{snake}_from_json");
1746    // Convert to Dart camelCase: "createChatCompletionRequestFromJson"
1747    rust_fn
1748        .split('_')
1749        .enumerate()
1750        .map(|(i, part)| {
1751            if i == 0 {
1752                part.to_string()
1753            } else {
1754                let mut chars = part.chars();
1755                match chars.next() {
1756                    None => String::new(),
1757                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1758                }
1759            }
1760        })
1761        .collect::<Vec<_>>()
1762        .join("")
1763}