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::fixture::{Assertion, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::ResolvedCrateConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions::pub_dev;
15use anyhow::Result;
16use std::cell::Cell;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23/// Dart e2e code generator.
24pub struct DartE2eCodegen;
25
26impl E2eCodegen for DartE2eCodegen {
27    fn generate(
28        &self,
29        groups: &[FixtureGroup],
30        e2e_config: &E2eConfig,
31        config: &ResolvedCrateConfig,
32        _type_defs: &[alef_core::ir::TypeDef],
33    ) -> Result<Vec<GeneratedFile>> {
34        let lang = self.language_name();
35        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37        let mut files = Vec::new();
38
39        // Resolve package config.
40        let dart_pkg = e2e_config.resolve_package("dart");
41        let pkg_name = dart_pkg
42            .as_ref()
43            .and_then(|p| p.name.as_ref())
44            .cloned()
45            .unwrap_or_else(|| config.dart_pubspec_name());
46        let pkg_path = dart_pkg
47            .as_ref()
48            .and_then(|p| p.path.as_ref())
49            .cloned()
50            .unwrap_or_else(|| "../../packages/dart".to_string());
51        let pkg_version = dart_pkg
52            .as_ref()
53            .and_then(|p| p.version.as_ref())
54            .cloned()
55            .or_else(|| config.resolved_version())
56            .unwrap_or_else(|| "0.1.0".to_string());
57
58        // Generate pubspec.yaml with http dependency for HTTP client tests.
59        files.push(GeneratedFile {
60            path: output_base.join("pubspec.yaml"),
61            content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
62            generated_header: false,
63        });
64
65        // Generate dart_test.yaml to limit parallelism — the mock server uses keep-alive
66        // connections and gets overwhelmed when test files run in parallel.
67        files.push(GeneratedFile {
68            path: output_base.join("dart_test.yaml"),
69            content: concat!(
70                "# Generated by alef — DO NOT EDIT.\n",
71                "# Run test files sequentially to avoid overwhelming the mock server with\n",
72                "# concurrent keep-alive connections.\n",
73                "concurrency: 1\n",
74            )
75            .to_string(),
76            generated_header: false,
77        });
78
79        let test_base = output_base.join("test");
80
81        // One test file per fixture group.
82        let bridge_class = config.dart_bridge_class_name();
83
84        for group in groups {
85            let active: Vec<&Fixture> = group
86                .fixtures
87                .iter()
88                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
89                .collect();
90
91            if active.is_empty() {
92                continue;
93            }
94
95            let filename = format!("{}_test.dart", sanitize_filename(&group.category));
96            let content = render_test_file(&group.category, &active, e2e_config, lang, &pkg_name, &bridge_class);
97            files.push(GeneratedFile {
98                path: test_base.join(filename),
99                content,
100                generated_header: true,
101            });
102        }
103
104        Ok(files)
105    }
106
107    fn language_name(&self) -> &'static str {
108        "dart"
109    }
110}
111
112// ---------------------------------------------------------------------------
113// Rendering
114// ---------------------------------------------------------------------------
115
116fn render_pubspec(
117    pkg_name: &str,
118    pkg_path: &str,
119    pkg_version: &str,
120    dep_mode: crate::config::DependencyMode,
121) -> String {
122    let test_ver = pub_dev::TEST_PACKAGE;
123    let http_ver = pub_dev::HTTP_PACKAGE;
124
125    let dep_block = match dep_mode {
126        crate::config::DependencyMode::Registry => {
127            format!("  {pkg_name}: ^{pkg_version}")
128        }
129        crate::config::DependencyMode::Local => {
130            format!("  {pkg_name}:\n    path: {pkg_path}")
131        }
132    };
133
134    let sdk = alef_core::template_versions::toolchain::DART_SDK_CONSTRAINT;
135    format!(
136        r#"name: e2e_dart
137version: 0.1.0
138publish_to: none
139
140environment:
141  sdk: "{sdk}"
142
143dependencies:
144{dep_block}
145
146dev_dependencies:
147  test: {test_ver}
148  http: {http_ver}
149"#
150    )
151}
152
153fn render_test_file(
154    category: &str,
155    fixtures: &[&Fixture],
156    e2e_config: &E2eConfig,
157    lang: &str,
158    pkg_name: &str,
159    bridge_class: &str,
160) -> String {
161    let mut out = String::new();
162    out.push_str(&hash::header(CommentStyle::DoubleSlash));
163
164    // Check if any fixture needs the http package (HTTP server tests).
165    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
166
167    // Check if any fixture needs Uint8List.fromList (batch item byte arrays).
168    let has_batch_byte_items = fixtures.iter().any(|f| {
169        let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
170        call_config.args.iter().any(|a| {
171            a.element_type.as_deref() == Some("BatchBytesItem") && resolve_field(&f.input, &a.field).is_array()
172        })
173    });
174
175    // Detect whether any fixture uses file_path or bytes args — if so, setUpAll must chdir
176    // to the test_documents directory so that relative paths like "docx/fake.docx" resolve.
177    // Mirrors the Ruby/Python conftest and Swift setUp patterns.
178    let needs_chdir = fixtures.iter().any(|f| {
179        if f.is_http_test() {
180            return false;
181        }
182        let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
183        call_config
184            .args
185            .iter()
186            .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
187    });
188
189    // Detect whether any non-HTTP fixture uses a handle arg — if so we need dart:convert
190    // to call jsonDecode when building the engine config from a JSON string.
191    let has_handle_args = fixtures.iter().any(|f| {
192        if f.is_http_test() {
193            return false;
194        }
195        let call_config = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
196        call_config.args.iter().any(|a| a.arg_type == "handle")
197    });
198
199    let _ = writeln!(out, "import 'package:test/test.dart';");
200    let _ = writeln!(out, "import 'dart:io';");
201    if has_batch_byte_items {
202        let _ = writeln!(out, "import 'dart:typed_data';");
203    }
204    let _ = writeln!(out, "import 'package:{pkg_name}/{pkg_name}.dart';");
205    // RustLib is the flutter_rust_bridge entrypoint; must be initialized before any FRB call.
206    // It lives in the FRB-generated frb_generated.dart inside `{pkg_name}_bridge_generated/`.
207    let _ = writeln!(
208        out,
209        "import 'package:{pkg_name}/src/{pkg_name}_bridge_generated/frb_generated.dart' show RustLib;"
210    );
211    if has_http_fixtures {
212        let _ = writeln!(out, "import 'dart:async';");
213    }
214    // dart:convert provides jsonDecode for handle-arg engine construction and HTTP response parsing.
215    if has_http_fixtures || has_handle_args {
216        let _ = writeln!(out, "import 'dart:convert';");
217    }
218    let _ = writeln!(out);
219
220    // Emit file-level HTTP client and serialization mutex.
221    //
222    // The shared HttpClient reuses keep-alive connections to minimize TCP overhead.
223    // The mutex (_lock) ensures requests are serialized within the file so the
224    // connection pool is not exercised concurrently by dart:test's async runner.
225    //
226    // _withRetry wraps the entire request closure with one automatic retry on
227    // transient connection errors (keep-alive connections can be silently closed
228    // by the server just as the client tries to reuse them).
229    if has_http_fixtures {
230        let _ = writeln!(out, "HttpClient _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
231        let _ = writeln!(out);
232        let _ = writeln!(out, "var _lock = Future<void>.value();");
233        let _ = writeln!(out);
234        let _ = writeln!(out, "Future<T> _serialized<T>(Future<T> Function() fn) async {{");
235        let _ = writeln!(out, "  final current = _lock;");
236        let _ = writeln!(out, "  final next = Completer<void>();");
237        let _ = writeln!(out, "  _lock = next.future;");
238        let _ = writeln!(out, "  try {{");
239        let _ = writeln!(out, "    await current;");
240        let _ = writeln!(out, "    return await fn();");
241        let _ = writeln!(out, "  }} finally {{");
242        let _ = writeln!(out, "    next.complete();");
243        let _ = writeln!(out, "  }}");
244        let _ = writeln!(out, "}}");
245        let _ = writeln!(out);
246        // The `fn` here should be the full request closure — on socket failure we
247        // recreate the HttpClient (drops old pooled connections) and retry once.
248        let _ = writeln!(out, "Future<T> _withRetry<T>(Future<T> Function() fn) async {{");
249        let _ = writeln!(out, "  try {{");
250        let _ = writeln!(out, "    return await fn();");
251        let _ = writeln!(out, "  }} on SocketException {{");
252        let _ = writeln!(out, "    _httpClient.close(force: true);");
253        let _ = writeln!(out, "    _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
254        let _ = writeln!(out, "    return fn();");
255        let _ = writeln!(out, "  }} on HttpException {{");
256        let _ = writeln!(out, "    _httpClient.close(force: true);");
257        let _ = writeln!(out, "    _httpClient = HttpClient()..maxConnectionsPerHost = 1;");
258        let _ = writeln!(out, "    return fn();");
259        let _ = writeln!(out, "  }}");
260        let _ = writeln!(out, "}}");
261        let _ = writeln!(out);
262    }
263
264    let _ = writeln!(out, "// E2e tests for category: {category}");
265    let _ = writeln!(out, "void main() {{");
266
267    // Emit setUpAll to initialize the flutter_rust_bridge before any test runs and,
268    // when fixtures load files by path, chdir to test_documents so that relative
269    // paths like "docx/fake.docx" resolve correctly.
270    //
271    // The test_documents directory lives two levels above e2e/dart/ (at the repo root).
272    // The FIXTURES_DIR environment variable can override this for CI environments.
273    let _ = writeln!(out, "  setUpAll(() async {{");
274    let _ = writeln!(out, "    await RustLib.init();");
275    if needs_chdir {
276        let test_docs_path = e2e_config.test_documents_relative_from(0);
277        let _ = writeln!(
278            out,
279            "    final _testDocs = Platform.environment['FIXTURES_DIR'] ?? '{test_docs_path}';"
280        );
281        let _ = writeln!(out, "    final _dir = Directory(_testDocs);");
282        let _ = writeln!(out, "    if (_dir.existsSync()) Directory.current = _dir;");
283    }
284    let _ = writeln!(out, "  }});");
285    let _ = writeln!(out);
286
287    // Close the shared client after all tests in this file complete.
288    if has_http_fixtures {
289        let _ = writeln!(out, "  tearDownAll(() => _httpClient.close());");
290        let _ = writeln!(out);
291    }
292
293    for fixture in fixtures {
294        render_test_case(&mut out, fixture, e2e_config, lang, bridge_class);
295    }
296
297    let _ = writeln!(out, "}}");
298    out
299}
300
301fn render_test_case(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig, lang: &str, bridge_class: &str) {
302    // HTTP fixtures: hit the mock server.
303    if let Some(http) = &fixture.http {
304        render_http_test_case(out, fixture, http);
305        return;
306    }
307
308    // Non-HTTP fixtures: render a call-based test using the resolved call config.
309    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
310    let call_overrides = call_config.overrides.get(lang);
311    let mut function_name = call_overrides
312        .and_then(|o| o.function.as_ref())
313        .cloned()
314        .unwrap_or_else(|| call_config.function.clone());
315    // Convert snake_case function names to camelCase for Dart conventions.
316    function_name = function_name
317        .split('_')
318        .enumerate()
319        .map(|(i, part)| {
320            if i == 0 {
321                part.to_string()
322            } else {
323                let mut chars = part.chars();
324                match chars.next() {
325                    None => String::new(),
326                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
327                }
328            }
329        })
330        .collect::<Vec<_>>()
331        .join("");
332    let result_var = &call_config.result_var;
333    let description = escape_dart(&fixture.description);
334    let fixture_id = &fixture.id;
335    // `is_async` retained for future use (e.g. non-FRB backends); unused with FRB since
336    // all wrappers return Future<T>.
337    let _is_async = call_overrides.and_then(|o| o.r#async).unwrap_or(call_config.r#async);
338
339    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
340
341    // Resolve options_type and options_via from per-fixture → per-call → default.
342    // These drive how `json_object` args are constructed:
343    //   options_via = "from_json" — call `createTypeNameFromJson(json: r'...')` bridge
344    //                               helper and pass the result as a named parameter `req:`.
345    //   All other values (or absent) — existing behaviour (batch arrays, config objects,
346    //   generic JSON arrays, or nothing).
347    let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
348    let options_via: &str = call_overrides
349        .and_then(|o| o.options_via.as_deref())
350        .unwrap_or("kwargs");
351
352    // Build argument list from fixture.input and call_config.args.
353    // Use `resolve_field` (respects the `field` path like "input.data") rather than
354    // looking up by `arg_def.name` directly — the name and the field key may differ.
355    //
356    // For `extract_file_sync` / `extract_file` fixtures that omit `mime_type`,
357    // derive the MIME from the path extension so `extractBytesSync`/`extractBytes`
358    // can be called (both require an explicit MIME type).
359    let file_path_for_mime: Option<&str> = call_config
360        .args
361        .iter()
362        .find(|a| a.arg_type == "file_path")
363        .and_then(|a| resolve_field(&fixture.input, &a.field).as_str());
364
365    // Detect whether this call converts a file_path arg to bytes at test-run time.
366    // Dart cannot pass OS-level file paths through the FRB bridge — the idiomatic API
367    // is always bytes. When a file_path arg is present (and no caller-supplied dart
368    // function override has already been applied), remap the function name:
369    //   extractFile      → extractBytes
370    //   extractFileSync  → extractBytesSync
371    let has_file_path_arg = call_config.args.iter().any(|a| a.arg_type == "file_path");
372    // Apply the remap only when no per-fixture dart override has already specified the
373    // function — if the fixture author set a dart-specific function name we trust it.
374    let caller_supplied_override = call_overrides.and_then(|o| o.function.as_ref()).is_some();
375    if has_file_path_arg && !caller_supplied_override {
376        function_name = match function_name.as_str() {
377            "extractFile" => "extractBytes".to_string(),
378            "extractFileSync" => "extractBytesSync".to_string(),
379            other => other.to_string(),
380        };
381    }
382
383    // setup_lines holds per-test statements that must precede the main call:
384    // engine construction (handle args) and URL building (mock_url args).
385    let mut setup_lines: Vec<String> = Vec::new();
386    let mut args = Vec::new();
387
388    for arg_def in &call_config.args {
389        match arg_def.arg_type.as_str() {
390            "mock_url" => {
391                let name = arg_def.name.clone();
392                if fixture.has_host_root_route() {
393                    let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
394                    setup_lines.push(format!(
395                        r#"final {name} = Platform.environment["{env_key}"] ?? (Platform.environment["MOCK_SERVER_URL"]! + "/fixtures/{fixture_id}");"#
396                    ));
397                } else {
398                    setup_lines.push(format!(
399                        r#"final {name} = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
400                    ));
401                }
402                args.push(name);
403                continue;
404            }
405            "handle" => {
406                let name = arg_def.name.clone();
407                let field = arg_def.field.strip_prefix("input.").unwrap_or(&arg_def.field);
408                let config_value = fixture.input.get(field).cloned().unwrap_or(serde_json::Value::Null);
409                // Derive the create-function name: "engine" → "createEngine".
410                let create_fn = {
411                    let mut chars = name.chars();
412                    let pascal = match chars.next() {
413                        None => String::new(),
414                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
415                    };
416                    format!("create{pascal}")
417                };
418                if config_value.is_null()
419                    || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
420                {
421                    setup_lines.push(format!("final {name} = await {bridge_class}.{create_fn}(null);"));
422                } else {
423                    let json_str = serde_json::to_string(&config_value).unwrap_or_default();
424                    let config_var = format!("{name}Config");
425                    setup_lines.push(format!(
426                        "final {config_var} = CrawlConfig.fromJson(jsonDecode(r'{json_str}') as Map<String, dynamic>);"
427                    ));
428                    setup_lines.push(format!(
429                        "final {name} = await {bridge_class}.{create_fn}({config_var});"
430                    ));
431                }
432                args.push(name);
433                continue;
434            }
435            _ => {}
436        }
437
438        let arg_value = resolve_field(&fixture.input, &arg_def.field);
439        match arg_def.arg_type.as_str() {
440            "bytes" | "file_path" => {
441                // `bytes`: value is a file path string; load file contents at test-run time.
442                // `file_path`: also loaded as bytes for dart — extractBytes/extractBytesSync is
443                // the idiomatic Dart API since the Dart runtime cannot pass OS-level file paths
444                // through the FFI bridge.
445                if let serde_json::Value::String(file_path) = arg_value {
446                    args.push(format!("File('{}').readAsBytesSync()", file_path));
447                }
448            }
449            "string" => {
450                // FRB generates all bridge method parameters as named (`{required T name}`)
451                // in Dart, so string args must be passed as `paramName: 'value'`.
452                // Convert the arg name from snake_case to camelCase for the Dart named param.
453                let dart_param_name = snake_to_camel(&arg_def.name);
454                match arg_value {
455                    serde_json::Value::String(s) => {
456                        args.push(format!("{dart_param_name}: '{}'", escape_dart(s)));
457                    }
458                    serde_json::Value::Null
459                        if arg_def.optional
460                        // Optional string absent from fixture — try to infer MIME from path
461                        // when the arg name looks like a MIME-type parameter.
462                        && arg_def.name == "mime_type" =>
463                    {
464                        let inferred = file_path_for_mime
465                            .and_then(mime_from_extension)
466                            .unwrap_or("application/octet-stream");
467                        args.push(format!("{dart_param_name}: '{inferred}'"));
468                    }
469                    // Other optional strings with null value are omitted.
470                    _ => {}
471                }
472            }
473            "json_object" => {
474                // Handle batch item arrays (BatchBytesItem / BatchFileItem).
475                if let Some(elem_type) = &arg_def.element_type {
476                    if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && arg_value.is_array() {
477                        let dart_items = emit_dart_batch_item_array(arg_value, elem_type);
478                        args.push(dart_items);
479                    }
480                } else if options_via == "from_json" {
481                    // `from_json` path: construct a typed mirror-struct via the generated
482                    // `create<TypeName>FromJson(json: '...')` bridge helper, then pass it
483                    // as the named FRB parameter `req: _var`.
484                    //
485                    // The helper is generated by `emit_from_json_fn` in the dart bridge-crate
486                    // generator and made available as a top-level function via the exported
487                    // `liter_llm_bridge_generated/lib.dart`. The parameter name used in the
488                    // bridge method call is always `req:` for single-request-object methods
489                    // (derived from the Rust IR param name).
490                    if let Some(opts_type) = options_type {
491                        if !arg_value.is_null() {
492                            let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
493                            // Escape for Dart single-quoted string literal (handles embedded quotes,
494                            // backslashes, and interpolation markers).
495                            let escaped_json = escape_dart(&json_str);
496                            let var_name = format!("_{}", arg_def.name);
497                            let dart_fn = type_name_to_create_from_json_dart(opts_type);
498                            setup_lines.push(format!("final {var_name} = await {dart_fn}(json: '{escaped_json}');"));
499                            // FRB bridge method param name is `req` for all single-request methods.
500                            // Use `req:` as the named argument label.
501                            args.push(format!("req: {var_name}"));
502                        }
503                    }
504                } else if arg_def.name == "config" {
505                    if let serde_json::Value::Object(map) = &arg_value {
506                        // Fixture provides config overrides — build an ExtractionConfig constructor
507                        // with defaults, overriding only the fields present in the fixture JSON.
508                        // This handles error-triggering configs like {force_ocr:true, disable_ocr:true}.
509                        if !map.is_empty() {
510                            args.push(emit_extraction_config_dart(map));
511                        }
512                    }
513                    // If config is null/absent, the wrapper supplies the default ExtractionConfig.
514                } else if arg_value.is_array() {
515                    // Generic JSON array (e.g. batch_urls: ["/page1", "/page2"]).
516                    // Decode via jsonDecode and cast to List<String> at test-run time.
517                    let json_str = serde_json::to_string(&arg_value).unwrap_or_default();
518                    let var_name = arg_def.name.clone();
519                    setup_lines.push(format!(
520                        "final {var_name} = (jsonDecode(r'{json_str}') as List<dynamic>).cast<String>();"
521                    ));
522                    args.push(var_name);
523                }
524            }
525            _ => {}
526        }
527    }
528
529    // Resolve client_factory: when set, tests create a client instance and call
530    // methods on it rather than using static bridge-class calls. This mirrors the
531    // go/python/zig pattern for stateful clients (e.g. liter-llm).
532    let client_factory: Option<&str> = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
533        e2e_config
534            .call
535            .overrides
536            .get(lang)
537            .and_then(|o| o.client_factory.as_deref())
538    });
539
540    // Convert factory name to camelCase (same rule as function_name above).
541    let client_factory_camel: Option<String> = client_factory.map(|f| {
542        f.split('_')
543            .enumerate()
544            .map(|(i, part)| {
545                if i == 0 {
546                    part.to_string()
547                } else {
548                    let mut chars = part.chars();
549                    match chars.next() {
550                        None => String::new(),
551                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
552                    }
553                }
554            })
555            .collect::<Vec<_>>()
556            .join("")
557    });
558
559    // All bridge methods return Future<T> because FRB v2 wraps every Rust
560    // function as async in Dart — even "sync" Rust functions. Always emit an async
561    // test body and await the call so the test framework waits for the future.
562    let _ = writeln!(out, "  test('{description}', () async {{");
563
564    let args_str = args.join(", ");
565    let receiver_class = call_overrides
566        .and_then(|o| o.class.as_ref())
567        .cloned()
568        .unwrap_or_else(|| bridge_class.to_string());
569
570    // When client_factory is set, determine the mock URL and emit client instantiation.
571    // The mock URL derivation follows the same has_host_root_route / plain-fixture split
572    // used by the mock_url arg handler above.
573    let (receiver, extra_setup): (String, Option<String>) = if let Some(factory) = &client_factory_camel {
574        let has_mock_url = call_config.args.iter().any(|a| a.arg_type == "mock_url");
575        let mock_url_setup = if !has_mock_url {
576            // No explicit mock_url arg — derive the URL inline.
577            if fixture.has_host_root_route() {
578                let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
579                Some(format!(
580                    "final _mockUrl = Platform.environment[\"{env_key}\"] ?? (Platform.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\");"
581                ))
582            } else {
583                Some(format!(
584                    r#"final _mockUrl = "${{Platform.environment["MOCK_SERVER_URL"] ?? "http://localhost:8080"}}/fixtures/{fixture_id}";"#
585                ))
586            }
587        } else {
588            None
589        };
590        let url_expr = if has_mock_url {
591            // A mock_url arg was emitted into setup_lines already — reuse the variable name
592            // from the first mock_url arg definition so we don't duplicate the URL.
593            call_config
594                .args
595                .iter()
596                .find(|a| a.arg_type == "mock_url")
597                .map(|a| a.name.clone())
598                .unwrap_or_else(|| "_mockUrl".to_string())
599        } else {
600            "_mockUrl".to_string()
601        };
602        let create_line = format!("final _client = await {receiver_class}.{factory}('test-key', baseUrl: {url_expr});");
603        let full_setup = if let Some(url_line) = mock_url_setup {
604            Some(format!("{url_line}\n    {create_line}"))
605        } else {
606            Some(create_line)
607        };
608        ("_client".to_string(), full_setup)
609    } else {
610        (receiver_class.clone(), None)
611    };
612
613    if expects_error && (!setup_lines.is_empty() || extra_setup.is_some()) {
614        // Wrap setup + call in an async lambda so any exception at any step is caught.
615        // flutter_rust_bridge 2.x decodes Rust errors as raw String values (not Exception
616        // subtypes), so throwsException will not match. Use throwsA(anything) instead.
617        let _ = writeln!(out, "    await expectLater(() async {{");
618        for line in &setup_lines {
619            let _ = writeln!(out, "      {line}");
620        }
621        if let Some(extra) = &extra_setup {
622            for line in extra.lines() {
623                let _ = writeln!(out, "      {line}");
624            }
625        }
626        let _ = writeln!(out, "      return {receiver}.{function_name}({args_str});");
627        let _ = writeln!(out, "    }}(), throwsA(anything));");
628    } else if expects_error {
629        // No setup lines, direct call — same throwsA(anything) rationale as above.
630        if let Some(extra) = &extra_setup {
631            for line in extra.lines() {
632                let _ = writeln!(out, "    {line}");
633            }
634        }
635        let _ = writeln!(
636            out,
637            "    await expectLater({receiver}.{function_name}({args_str}), throwsA(anything));"
638        );
639    } else {
640        for line in &setup_lines {
641            let _ = writeln!(out, "    {line}");
642        }
643        if let Some(extra) = &extra_setup {
644            for line in extra.lines() {
645                let _ = writeln!(out, "    {line}");
646            }
647        }
648        let _ = writeln!(
649            out,
650            "    final {result_var} = await {receiver}.{function_name}({args_str});"
651        );
652        for assertion in &fixture.assertions {
653            render_assertion_dart(out, assertion, result_var);
654        }
655    }
656
657    let _ = writeln!(out, "  }});");
658    let _ = writeln!(out);
659}
660
661/// Render a single fixture assertion as a Dart `expect(...)` call against the
662/// non-HTTP result variable.
663///
664/// The non-HTTP path does not have access to the [`FieldResolver`] / enum-field
665/// metadata used by the HTTP-style renderers, so this helper deliberately emits
666/// the simplest possible accessors. It mirrors the assertion vocabulary used by
667/// other backends (`equals`, `field_equals`, `contains`, `not_null`,
668/// `not_error`) and skips unknown assertion types with a comment.
669fn render_assertion_dart(out: &mut String, assertion: &Assertion, result_var: &str) {
670    let field_accessor = match assertion.field.as_deref() {
671        Some(f) if !f.is_empty() => format!("{result_var}.{}", snake_to_camel(f)),
672        _ => result_var.to_string(),
673    };
674
675    let format_value = |val: &serde_json::Value| -> String {
676        match val {
677            serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
678            serde_json::Value::Bool(b) => b.to_string(),
679            serde_json::Value::Number(n) => n.to_string(),
680            serde_json::Value::Null => "null".to_string(),
681            other => format!("'{}'", escape_dart(&other.to_string())),
682        }
683    };
684
685    match assertion.assertion_type.as_str() {
686        "equals" | "field_equals" => {
687            if let Some(expected) = &assertion.value {
688                let dart_val = format_value(expected);
689                let _ = writeln!(out, "    expect({field_accessor}, equals({dart_val}));");
690            } else {
691                let _ = writeln!(
692                    out,
693                    "    // skipped: '{}' assertion missing value",
694                    assertion.assertion_type
695                );
696            }
697        }
698        "contains" => {
699            if let Some(expected) = &assertion.value {
700                let dart_val = format_value(expected);
701                let _ = writeln!(out, "    expect({field_accessor}, contains({dart_val}));");
702            } else {
703                let _ = writeln!(out, "    // skipped: 'contains' assertion missing value");
704            }
705        }
706        "not_null" => {
707            let _ = writeln!(out, "    expect({field_accessor}, isNotNull);");
708        }
709        "not_error" => {
710            // No-op: a thrown error from `await` would have failed the test already.
711        }
712        other => {
713            let _ = writeln!(out, "    // skipped: unknown assertion type '{other}'");
714        }
715    }
716}
717
718/// Converts a snake_case JSON key to Dart camelCase.
719fn snake_to_camel(s: &str) -> String {
720    let mut result = String::with_capacity(s.len());
721    let mut next_upper = false;
722    for ch in s.chars() {
723        if ch == '_' {
724            next_upper = true;
725        } else if next_upper {
726            result.extend(ch.to_uppercase());
727            next_upper = false;
728        } else {
729            result.push(ch);
730        }
731    }
732    result
733}
734
735/// Emits a Dart `ExtractionConfig(...)` constructor with default values, overriding
736/// fields present in `overrides` (from fixture JSON, snake_case keys).
737///
738/// Only simple scalar overrides (bool, int) are supported. Complex nested types
739/// (ocr, chunking, etc.) are left at their defaults (null).
740fn emit_extraction_config_dart(overrides: &serde_json::Map<String, serde_json::Value>) -> String {
741    // Collect scalar overrides; convert keys to camelCase.
742    let mut field_overrides: std::collections::HashMap<String, String> = std::collections::HashMap::new();
743    for (key, val) in overrides {
744        let camel = snake_to_camel(key);
745        let dart_val = match val {
746            serde_json::Value::Bool(b) => {
747                if *b {
748                    "true".to_string()
749                } else {
750                    "false".to_string()
751                }
752            }
753            serde_json::Value::Number(n) => n.to_string(),
754            serde_json::Value::String(s) => format!("'{s}'"),
755            _ => continue, // skip complex nested objects
756        };
757        field_overrides.insert(camel, dart_val);
758    }
759
760    let use_cache = field_overrides.remove("useCache").unwrap_or_else(|| "true".to_string());
761    let enable_quality_processing = field_overrides
762        .remove("enableQualityProcessing")
763        .unwrap_or_else(|| "true".to_string());
764    let force_ocr = field_overrides
765        .remove("forceOcr")
766        .unwrap_or_else(|| "false".to_string());
767    let disable_ocr = field_overrides
768        .remove("disableOcr")
769        .unwrap_or_else(|| "false".to_string());
770    let include_document_structure = field_overrides
771        .remove("includeDocumentStructure")
772        .unwrap_or_else(|| "false".to_string());
773    let max_archive_depth = field_overrides
774        .remove("maxArchiveDepth")
775        .unwrap_or_else(|| "3".to_string());
776
777    format!(
778        "ExtractionConfig(useCache: {use_cache}, enableQualityProcessing: {enable_quality_processing}, forceOcr: {force_ocr}, disableOcr: {disable_ocr}, resultFormat: ResultFormat.unified, outputFormat: OutputFormat.plain(), includeDocumentStructure: {include_document_structure}, maxArchiveDepth: {max_archive_depth})"
779    )
780}
781
782// ---------------------------------------------------------------------------
783// HTTP server test rendering — DartTestClientRenderer impl + thin driver wrapper
784// ---------------------------------------------------------------------------
785
786/// Renderer that emits `package:test` `test(...)` blocks using `dart:io HttpClient`
787/// against the mock server (`Platform.environment['MOCK_SERVER_URL']`).
788///
789/// Skipped tests are emitted as self-contained stubs (complete test block with
790/// `markTestSkipped`) entirely inside `render_test_open`. `render_test_close` uses
791/// `in_skip` to emit the right closing token: nothing extra for skip stubs (already
792/// closed) vs. `})));` for regular tests.
793///
794/// `is_redirect` must be set to `true` before invoking the shared driver for 3xx
795/// fixtures so that `render_call` can inject `ioReq.followRedirects = false` after
796/// the `openUrl` call.
797struct DartTestClientRenderer {
798    /// Set to `true` when `render_test_open` is called with a skip reason so that
799    /// `render_test_close` can match the opening shape.
800    in_skip: Cell<bool>,
801    /// Pre-set to `true` by the thin wrapper when the fixture expects a 3xx response.
802    /// `render_call` injects `ioReq.followRedirects = false` when this is `true`.
803    is_redirect: Cell<bool>,
804}
805
806impl DartTestClientRenderer {
807    fn new(is_redirect: bool) -> Self {
808        Self {
809            in_skip: Cell::new(false),
810            is_redirect: Cell::new(is_redirect),
811        }
812    }
813}
814
815impl client::TestClientRenderer for DartTestClientRenderer {
816    fn language_name(&self) -> &'static str {
817        "dart"
818    }
819
820    /// Emit the test opening.
821    ///
822    /// For skipped fixtures: emit the entire self-contained stub (open + body +
823    /// close + blank line) and set `in_skip = true` so `render_test_close` is a
824    /// no-op.
825    ///
826    /// For active fixtures: emit `test('desc', () => _serialized(() => _withRetry(() async {`
827    /// leaving the block open for the assertion primitives.
828    fn render_test_open(&self, out: &mut String, _fn_name: &str, description: &str, skip_reason: Option<&str>) {
829        let escaped_desc = escape_dart(description);
830        if let Some(reason) = skip_reason {
831            let escaped_reason = escape_dart(reason);
832            let _ = writeln!(out, "  test('{escaped_desc}', () {{");
833            let _ = writeln!(out, "    markTestSkipped('{escaped_reason}');");
834            let _ = writeln!(out, "  }});");
835            let _ = writeln!(out);
836            self.in_skip.set(true);
837        } else {
838            let _ = writeln!(
839                out,
840                "  test('{escaped_desc}', () => _serialized(() => _withRetry(() async {{"
841            );
842            self.in_skip.set(false);
843        }
844    }
845
846    /// Emit the test closing token.
847    ///
848    /// No-op for skip stubs (the stub was fully closed in `render_test_open`).
849    /// Emits `})));` followed by a blank line for regular tests.
850    fn render_test_close(&self, out: &mut String) {
851        if self.in_skip.get() {
852            // Stub was already closed in render_test_open.
853            return;
854        }
855        let _ = writeln!(out, "  }})));");
856        let _ = writeln!(out);
857    }
858
859    /// Emit the full `dart:io HttpClient` request scaffolding.
860    ///
861    /// Emits:
862    /// - URL construction from `MOCK_SERVER_URL`.
863    /// - `_httpClient.openUrl(method, uri)`.
864    /// - `followRedirects = false` when `is_redirect` is pre-set on the renderer.
865    /// - Content-Type header, request headers, cookies, optional body bytes.
866    /// - `ioReq.close()` → `ioResp`.
867    /// - Response-body drain into `bodyStr` (skipped for redirect responses).
868    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
869        // dart:io restricted headers (handled automatically by the HTTP stack).
870        const DART_RESTRICTED_HEADERS: &[&str] = &["content-length", "host", "transfer-encoding"];
871
872        let method = ctx.method.to_uppercase();
873        let escaped_method = escape_dart(&method);
874
875        // Fixture path is `/fixtures/<id>` — extract the id portion for URL construction.
876        let fixture_path = escape_dart(ctx.path);
877
878        // Determine effective content-type.
879        let has_explicit_content_type = ctx.headers.keys().any(|k| k.to_lowercase() == "content-type");
880        let effective_content_type = if has_explicit_content_type {
881            ctx.headers
882                .iter()
883                .find(|(k, _)| k.to_lowercase() == "content-type")
884                .map(|(_, v)| v.as_str())
885                .unwrap_or("application/json")
886        } else if ctx.body.is_some() {
887            ctx.content_type.unwrap_or("application/json")
888        } else {
889            ""
890        };
891
892        let _ = writeln!(
893            out,
894            "    final baseUrl = Platform.environment['MOCK_SERVER_URL'] ?? 'http://localhost:8080';"
895        );
896        let _ = writeln!(out, "    final uri = Uri.parse('$baseUrl{fixture_path}');");
897        let _ = writeln!(
898            out,
899            "    final ioReq = await _httpClient.openUrl('{escaped_method}', uri);"
900        );
901
902        // Disable automatic redirect following for 3xx fixtures so the test can
903        // assert on the redirect status code itself.
904        if self.is_redirect.get() {
905            let _ = writeln!(out, "    ioReq.followRedirects = false;");
906        }
907
908        // Set content-type header.
909        if !effective_content_type.is_empty() {
910            let escaped_ct = escape_dart(effective_content_type);
911            let _ = writeln!(out, "    ioReq.headers.set('content-type', '{escaped_ct}');");
912        }
913
914        // Set request headers (skip dart:io restricted headers and content-type, already handled).
915        let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
916        header_pairs.sort_by_key(|(k, _)| k.as_str());
917        for (name, value) in &header_pairs {
918            if DART_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
919                continue;
920            }
921            if name.to_lowercase() == "content-type" {
922                continue; // Already handled above.
923            }
924            let escaped_name = escape_dart(&name.to_lowercase());
925            let escaped_value = escape_dart(value);
926            let _ = writeln!(out, "    ioReq.headers.set('{escaped_name}', '{escaped_value}');");
927        }
928
929        // Add cookies.
930        if !ctx.cookies.is_empty() {
931            let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
932            cookie_pairs.sort_by_key(|(k, _)| k.as_str());
933            let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
934            let cookie_header = escape_dart(&cookie_str.join("; "));
935            let _ = writeln!(out, "    ioReq.headers.set('cookie', '{cookie_header}');");
936        }
937
938        // Write body bytes if present (bypass charset-based encoding issues).
939        if let Some(body) = ctx.body {
940            let json_str = serde_json::to_string(body).unwrap_or_default();
941            let escaped = escape_dart(&json_str);
942            let _ = writeln!(out, "    final bodyBytes = utf8.encode('{escaped}');");
943            let _ = writeln!(out, "    ioReq.add(bodyBytes);");
944        }
945
946        let _ = writeln!(out, "    final ioResp = await ioReq.close();");
947        // Drain the response body to bind `bodyStr` for assertion primitives and to
948        // allow the server to cleanly close the connection (prevents RST packets).
949        // Redirect responses have no body to drain — skip to avoid a potential hang.
950        if !self.is_redirect.get() {
951            let _ = writeln!(out, "    final bodyStr = await ioResp.transform(utf8.decoder).join();");
952        };
953    }
954
955    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
956        let _ = writeln!(
957            out,
958            "    expect(ioResp.statusCode, equals({status}), reason: 'status code mismatch');"
959        );
960    }
961
962    /// Emit a single header assertion, handling special tokens `<<present>>`,
963    /// `<<absent>>`, and `<<uuid>>`.
964    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
965        let escaped_name = escape_dart(&name.to_lowercase());
966        match expected {
967            "<<present>>" => {
968                let _ = writeln!(
969                    out,
970                    "    expect(ioResp.headers.value('{escaped_name}'), isNotNull, reason: 'header {escaped_name} should be present');"
971                );
972            }
973            "<<absent>>" => {
974                let _ = writeln!(
975                    out,
976                    "    expect(ioResp.headers.value('{escaped_name}'), isNull, reason: 'header {escaped_name} should be absent');"
977                );
978            }
979            "<<uuid>>" => {
980                let _ = writeln!(
981                    out,
982                    "    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');"
983                );
984            }
985            exact => {
986                let escaped_value = escape_dart(exact);
987                let _ = writeln!(
988                    out,
989                    "    expect(ioResp.headers.value('{escaped_name}'), contains('{escaped_value}'), reason: 'header {escaped_name} mismatch');"
990                );
991            }
992        }
993    }
994
995    /// Emit an exact-equality body assertion.
996    ///
997    /// String bodies are compared as decoded text; structured JSON bodies are
998    /// compared via `jsonDecode`.
999    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1000        match expected {
1001            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1002                let json_str = serde_json::to_string(expected).unwrap_or_default();
1003                let escaped = escape_dart(&json_str);
1004                let _ = writeln!(out, "    final bodyJson = jsonDecode(bodyStr);");
1005                let _ = writeln!(out, "    final expectedJson = jsonDecode('{escaped}');");
1006                let _ = writeln!(
1007                    out,
1008                    "    expect(bodyJson, equals(expectedJson), reason: 'body mismatch');"
1009                );
1010            }
1011            serde_json::Value::String(s) => {
1012                let escaped = escape_dart(s);
1013                let _ = writeln!(
1014                    out,
1015                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1016                );
1017            }
1018            other => {
1019                let escaped = escape_dart(&other.to_string());
1020                let _ = writeln!(
1021                    out,
1022                    "    expect(bodyStr.trim(), equals('{escaped}'), reason: 'body mismatch');"
1023                );
1024            }
1025        }
1026    }
1027
1028    /// Emit partial-body assertions — every key in `expected` must match the
1029    /// corresponding field in the parsed JSON response.
1030    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
1031        let _ = writeln!(
1032            out,
1033            "    final partialJson = jsonDecode(bodyStr) as Map<String, dynamic>;"
1034        );
1035        if let Some(obj) = expected.as_object() {
1036            for (idx, (key, val)) in obj.iter().enumerate() {
1037                let escaped_key = escape_dart(key);
1038                let json_val = serde_json::to_string(val).unwrap_or_default();
1039                let escaped_val = escape_dart(&json_val);
1040                // Use an index-based variable name so keys with special characters
1041                // don't produce invalid Dart identifiers.
1042                let _ = writeln!(out, "    final _expectedField{idx} = jsonDecode('{escaped_val}');");
1043                let _ = writeln!(
1044                    out,
1045                    "    expect(partialJson['{escaped_key}'], equals(_expectedField{idx}), reason: 'partial body field \\'{escaped_key}\\' mismatch');"
1046                );
1047            }
1048        }
1049    }
1050
1051    /// Emit validation-error assertions for 422 responses.
1052    fn render_assert_validation_errors(
1053        &self,
1054        out: &mut String,
1055        _response_var: &str,
1056        errors: &[ValidationErrorExpectation],
1057    ) {
1058        let _ = writeln!(out, "    final errBody = jsonDecode(bodyStr) as Map<String, dynamic>;");
1059        let _ = writeln!(out, "    final errList = (errBody['errors'] ?? []) as List<dynamic>;");
1060        for ve in errors {
1061            let loc_dart: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_dart(s))).collect();
1062            let loc_str = loc_dart.join(", ");
1063            let escaped_msg = escape_dart(&ve.msg);
1064            let _ = writeln!(
1065                out,
1066                "    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}');"
1067            );
1068        }
1069    }
1070}
1071
1072/// Render a `package:test` `test(...)` block for an HTTP server fixture.
1073///
1074/// Delegates to the shared [`client::http_call::render_http_test`] driver via
1075/// [`DartTestClientRenderer`]. HTTP 101 (WebSocket upgrade) fixtures are emitted
1076/// as skip stubs before reaching the driver because `dart:io HttpClient` cannot
1077/// handle protocol-switch responses.
1078fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
1079    // HTTP 101 (WebSocket upgrade) — dart:io HttpClient cannot handle upgrade responses.
1080    if http.expected_response.status_code == 101 {
1081        let description = escape_dart(&fixture.description);
1082        let _ = writeln!(out, "  test('{description}', () {{");
1083        let _ = writeln!(
1084            out,
1085            "    markTestSkipped('Skipped: Dart HttpClient cannot handle 101 Switching Protocols responses');"
1086        );
1087        let _ = writeln!(out, "  }});");
1088        let _ = writeln!(out);
1089        return;
1090    }
1091
1092    // Pre-set `is_redirect` on the renderer so `render_call` can inject
1093    // `ioReq.followRedirects = false` for 3xx fixtures. The shared driver has no
1094    // concept of expected status code so we thread it through renderer state.
1095    let is_redirect = http.expected_response.status_code / 100 == 3;
1096    client::http_call::render_http_test(out, &DartTestClientRenderer::new(is_redirect), fixture);
1097}
1098
1099/// Infer a MIME type from a file path extension.
1100///
1101/// Returns `None` when the extension is unknown so the caller can supply a fallback.
1102/// Used in dart e2e tests when a fixture omits `mime_type` but uses a `file_path` arg.
1103fn mime_from_extension(path: &str) -> Option<&'static str> {
1104    let ext = path.rsplit('.').next()?;
1105    match ext.to_lowercase().as_str() {
1106        "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
1107        "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
1108        "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
1109        "pdf" => Some("application/pdf"),
1110        "txt" | "text" => Some("text/plain"),
1111        "html" | "htm" => Some("text/html"),
1112        "json" => Some("application/json"),
1113        "xml" => Some("application/xml"),
1114        "csv" => Some("text/csv"),
1115        "md" | "markdown" => Some("text/markdown"),
1116        "png" => Some("image/png"),
1117        "jpg" | "jpeg" => Some("image/jpeg"),
1118        "gif" => Some("image/gif"),
1119        "zip" => Some("application/zip"),
1120        "odt" => Some("application/vnd.oasis.opendocument.text"),
1121        "ods" => Some("application/vnd.oasis.opendocument.spreadsheet"),
1122        "odp" => Some("application/vnd.oasis.opendocument.presentation"),
1123        "rtf" => Some("application/rtf"),
1124        "epub" => Some("application/epub+zip"),
1125        "msg" => Some("application/vnd.ms-outlook"),
1126        "eml" => Some("message/rfc822"),
1127        _ => None,
1128    }
1129}
1130
1131/// Emit Dart constructors for a batch item array (`BatchBytesItem` or `BatchFileItem`).
1132///
1133/// Returns a Dart list literal like:
1134/// ```dart
1135/// [BatchBytesItem(content: Uint8List.fromList([72, 101, ...]), mimeType: 'text/plain')]
1136/// ```
1137fn emit_dart_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1138    let items: Vec<String> = arr
1139        .as_array()
1140        .map(|a| a.as_slice())
1141        .unwrap_or_default()
1142        .iter()
1143        .filter_map(|item| {
1144            let obj = item.as_object()?;
1145            match elem_type {
1146                "BatchBytesItem" => {
1147                    let content_bytes = obj
1148                        .get("content")
1149                        .and_then(|v| v.as_array())
1150                        .map(|arr| {
1151                            let nums: Vec<String> =
1152                                arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1153                            format!("Uint8List.fromList([{}])", nums.join(", "))
1154                        })
1155                        .unwrap_or_else(|| "Uint8List(0)".to_string());
1156                    let mime_type = obj
1157                        .get("mime_type")
1158                        .and_then(|v| v.as_str())
1159                        .unwrap_or("application/octet-stream");
1160                    Some(format!(
1161                        "BatchBytesItem(content: {content_bytes}, mimeType: '{}')",
1162                        escape_dart(mime_type)
1163                    ))
1164                }
1165                "BatchFileItem" => {
1166                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1167                    Some(format!("BatchFileItem(path: '{}')", escape_dart(path)))
1168                }
1169                _ => None,
1170            }
1171        })
1172        .collect();
1173    format!("[{}]", items.join(", "))
1174}
1175
1176/// Escape a string for embedding in a Dart single-quoted string literal.
1177fn escape_dart(s: &str) -> String {
1178    s.replace('\\', "\\\\")
1179        .replace('\'', "\\'")
1180        .replace('\n', "\\n")
1181        .replace('\r', "\\r")
1182        .replace('\t', "\\t")
1183        .replace('$', "\\$")
1184}
1185
1186/// Derive the Dart top-level helper function name for constructing a mirror type from JSON.
1187///
1188/// The alef dart bridge-crate generator emits a Rust free function
1189/// `create_<snake_type>_from_json(json: String)` for each non-opaque mirror struct.
1190/// FRB generates the corresponding Dart function as `createTypeNameFromJson` (camelCase).
1191///
1192/// Example: `"ChatCompletionRequest"` → `"createChatCompletionRequestFromJson"`.
1193fn type_name_to_create_from_json_dart(type_name: &str) -> String {
1194    // Convert PascalCase type name to snake_case.
1195    let mut snake = String::with_capacity(type_name.len() + 8);
1196    for (i, ch) in type_name.char_indices() {
1197        if ch.is_uppercase() {
1198            if i > 0 {
1199                snake.push('_');
1200            }
1201            snake.extend(ch.to_lowercase());
1202        } else {
1203            snake.push(ch);
1204        }
1205    }
1206    // snake is now e.g. "chat_completion_request"
1207    // Full Rust function name: "create_chat_completion_request_from_json"
1208    let rust_fn = format!("create_{snake}_from_json");
1209    // Convert to Dart camelCase: "createChatCompletionRequestFromJson"
1210    rust_fn
1211        .split('_')
1212        .enumerate()
1213        .map(|(i, part)| {
1214            if i == 0 {
1215                part.to_string()
1216            } else {
1217                let mut chars = part.chars();
1218                match chars.next() {
1219                    None => String::new(),
1220                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1221                }
1222            }
1223        })
1224        .collect::<Vec<_>>()
1225        .join("")
1226}