Skip to main content

alef_e2e/codegen/
gleam.rs

1//! Gleam e2e test generator using gleeunit/should.
2//!
3//! Generates `packages/gleam/test/<crate>_test.gleam` files from JSON fixtures.
4//! HTTP fixtures hit the mock server at `MOCK_SERVER_URL/fixtures/<id>` using
5//! the `gleam_httpc` HTTP client library. Non-HTTP fixtures without a gleam-specific
6//! call override emit a skip stub.
7
8use crate::config::E2eConfig;
9use crate::escape::{escape_gleam, sanitize_filename, sanitize_ident};
10use crate::field_access::FieldResolver;
11use crate::fixture::{Assertion, Fixture, FixtureGroup, ValidationErrorExpectation};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::hash::{self, CommentStyle};
15use anyhow::Result;
16use heck::{ToPascalCase, ToSnakeCase};
17use std::collections::HashSet;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22use super::client;
23
24/// Gleam e2e code generator.
25pub struct GleamE2eCodegen;
26
27impl E2eCodegen for GleamE2eCodegen {
28    fn generate(
29        &self,
30        groups: &[FixtureGroup],
31        e2e_config: &E2eConfig,
32        config: &ResolvedCrateConfig,
33        _type_defs: &[alef_core::ir::TypeDef],
34    ) -> Result<Vec<GeneratedFile>> {
35        let lang = self.language_name();
36        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
37
38        let mut files = Vec::new();
39
40        // Resolve call config with overrides.
41        let call = &e2e_config.call;
42        let overrides = call.overrides.get(lang);
43        let module_path = overrides
44            .and_then(|o| o.module.as_ref())
45            .cloned()
46            .unwrap_or_else(|| call.module.clone());
47        let function_name = overrides
48            .and_then(|o| o.function.as_ref())
49            .cloned()
50            .unwrap_or_else(|| call.function.clone());
51        let result_var = &call.result_var;
52
53        // Resolve package config.
54        let gleam_pkg = e2e_config.resolve_package("gleam");
55        let pkg_path = gleam_pkg
56            .as_ref()
57            .and_then(|p| p.path.as_ref())
58            .cloned()
59            .unwrap_or_else(|| "../../packages/gleam".to_string());
60        let pkg_name = gleam_pkg
61            .as_ref()
62            .and_then(|p| p.name.as_ref())
63            .cloned()
64            .unwrap_or_else(|| config.name.to_snake_case());
65
66        // Generate gleam.toml.
67        files.push(GeneratedFile {
68            path: output_base.join("gleam.toml"),
69            content: render_gleam_toml(&pkg_path, &pkg_name, e2e_config.dep_mode),
70            generated_header: false,
71        });
72
73        // OTP application atom. Defaults to the snake-cased downstream crate
74        // name (matches `pkg_name`); kept as a separate binding because
75        // `application:ensure_all_started/1` and the Erlang shim function
76        // identifier both interpolate this same atom.
77        let app_name = pkg_name.clone();
78
79        // Gleam requires a `src/` directory even for test-only projects.
80        // Emit a helper module with `read_file_bytes` external for loading test
81        // documents as BitArray at runtime.
82        let e2e_helpers = format!(
83            "// Generated by alef. Do not edit by hand.\n\
84            // E2e helper module — provides file-reading utilities for Gleam tests.\n\
85            import gleam/dynamic\n\
86            \n\
87            /// Read a file into a BitArray via the Erlang :file module.\n\
88            /// The path is relative to the e2e working directory when `gleam test` runs.\n\
89            @external(erlang, \"file\", \"read_file\")\n\
90            pub fn read_file_bytes(path: String) -> Result(BitArray, dynamic.Dynamic)\n\
91            \n\
92            /// Ensure the {app_name} OTP application and all its dependencies are started.\n\
93            /// This is required when running `gleam test` outside of `mix test`, since the\n\
94            /// Rustler NIF init hook needs the :{app_name} application to be started before\n\
95            /// any binding-native functions can be called.\n\
96            /// Calls the Erlang shim e2e_startup:start_app/0.\n\
97            @external(erlang, \"e2e_startup\", \"start_app\")\n\
98            pub fn start_app() -> Nil\n",
99        );
100        // Erlang shim module that starts the configured OTP application and all deps.
101        // Compiled alongside the Gleam source when gleam test is run.
102        // Must start elixir first (provides Elixir.Application used by Rustler NIF init),
103        // then ensure the downstream-binding OTP application and its transitive deps are running.
104        let erlang_startup = format!(
105            "%% Generated by alef. Do not edit by hand.\n\
106            %% Starts the {app_name} OTP application and all its dependencies.\n\
107            %% Called by e2e_gleam_test.main/0 before gleeunit.main/0.\n\
108            -module(e2e_startup).\n\
109            -export([start_app/0]).\n\
110            \n\
111            start_app() ->\n\
112            \x20\x20\x20\x20%% Elixir runtime must be started before {app_name} NIF init\n\
113            \x20\x20\x20\x20%% because Rustler uses Elixir.Application.app_dir/2 to locate the .so.\n\
114            \x20\x20\x20\x20{{ok, _}} = application:ensure_all_started(elixir),\n\
115            \x20\x20\x20\x20{{ok, _}} = application:ensure_all_started({app_name}),\n\
116            \x20\x20\x20\x20nil.\n",
117        );
118        files.push(GeneratedFile {
119            path: output_base.join("src").join("e2e_gleam.gleam"),
120            content: e2e_helpers,
121            generated_header: false,
122        });
123        files.push(GeneratedFile {
124            path: output_base.join("src").join("e2e_startup.erl"),
125            content: erlang_startup,
126            generated_header: false,
127        });
128
129        // Track whether any test file was emitted.
130        let mut any_tests = false;
131
132        // Generate test files per category.
133        for group in groups {
134            let active: Vec<&Fixture> = group
135                .fixtures
136                .iter()
137                // Include both HTTP and non-HTTP fixtures. Filter out those marked as skip.
138                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
139                // gleam_httpc cannot follow HTTP/1.1 protocol upgrades (101 Switching
140                // Protocols), so skip WebSocket-upgrade fixtures whose request advertises
141                // Upgrade: websocket. The server returns 101 and gleam_httpc times out.
142                .filter(|f| {
143                    if let Some(http) = &f.http {
144                        let has_upgrade = http
145                            .request
146                            .headers
147                            .iter()
148                            .any(|(k, v)| k.eq_ignore_ascii_case("upgrade") && v.eq_ignore_ascii_case("websocket"));
149                        !has_upgrade
150                    } else {
151                        true
152                    }
153                })
154                // For non-HTTP fixtures, include all (will use default or override call config).
155                // Gleam always has a call override or can use the default call config.
156                .collect();
157
158            if active.is_empty() {
159                continue;
160            }
161
162            let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
163            let field_resolver = FieldResolver::new(
164                &e2e_config.fields,
165                &e2e_config.fields_optional,
166                &e2e_config.result_fields,
167                &e2e_config.fields_array,
168                &e2e_config.fields_method_calls,
169            );
170            // Look up gleam-specific config for element_type → record-constructor
171            // recipes. Empty slice when the downstream hasn't configured any.
172            let element_constructors: &[alef_core::config::GleamElementConstructor] = config
173                .gleam
174                .as_ref()
175                .map(|g| g.element_constructors.as_slice())
176                .unwrap_or(&[]);
177            // Optional wrapper template used when a json_object arg has no
178            // matching element_type recipe.
179            let json_object_wrapper: Option<&str> =
180                config.gleam.as_ref().and_then(|g| g.json_object_wrapper.as_deref());
181            let content = render_test_file(
182                &group.category,
183                &active,
184                e2e_config,
185                &module_path,
186                &function_name,
187                result_var,
188                &e2e_config.call.args,
189                &field_resolver,
190                &e2e_config.fields_enum,
191                element_constructors,
192                json_object_wrapper,
193            );
194            files.push(GeneratedFile {
195                path: output_base.join("test").join(filename),
196                content,
197                generated_header: true,
198            });
199            any_tests = true;
200        }
201
202        // Always emit the gleeunit entry module — `gleam test` invokes
203        // `<package>_test.main()` to discover and run all `_test.gleam` files.
204        // When no fixture-driven tests exist, also include a tiny smoke test so
205        // the suite is non-empty.
206        let entry = if any_tests {
207            concat!(
208                "// Generated by alef. Do not edit by hand.\n",
209                "import gleeunit\n",
210                "import e2e_gleam\n",
211                "\n",
212                "pub fn main() {\n",
213                "  let _ = e2e_gleam.start_app()\n",
214                "  gleeunit.main()\n",
215                "}\n",
216            )
217            .to_string()
218        } else {
219            concat!(
220                "// Generated by alef. Do not edit by hand.\n",
221                "// No fixture-driven tests for Gleam — e2e tests require HTTP fixtures\n",
222                "// or non-HTTP fixtures with gleam-specific call overrides.\n",
223                "import gleeunit\n",
224                "import gleeunit/should\n",
225                "\n",
226                "pub fn main() {\n",
227                "  gleeunit.main()\n",
228                "}\n",
229                "\n",
230                "pub fn compilation_smoke_test() {\n",
231                "  True |> should.equal(True)\n",
232                "}\n",
233            )
234            .to_string()
235        };
236        files.push(GeneratedFile {
237            path: output_base.join("test").join("e2e_gleam_test.gleam"),
238            content: entry,
239            generated_header: false,
240        });
241
242        Ok(files)
243    }
244
245    fn language_name(&self) -> &'static str {
246        "gleam"
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Rendering
252// ---------------------------------------------------------------------------
253
254fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
255    use alef_core::template_versions::hex;
256    let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
257    let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
258    let gleam_httpc = hex::GLEAM_HTTPC_VERSION_RANGE;
259    let envoy = hex::ENVOY_VERSION_RANGE;
260    let deps = match dep_mode {
261        crate::config::DependencyMode::Registry => {
262            format!(
263                r#"{pkg_name} = ">= 0.1.0"
264gleam_stdlib = "{stdlib}"
265gleeunit = "{gleeunit}"
266gleam_httpc = "{gleam_httpc}"
267gleam_http = ">= 4.0.0 and < 5.0.0"
268envoy = "{envoy}""#
269            )
270        }
271        crate::config::DependencyMode::Local => {
272            format!(
273                r#"{pkg_name} = {{ path = "{pkg_path}" }}
274gleam_stdlib = "{stdlib}"
275gleeunit = "{gleeunit}"
276gleam_httpc = "{gleam_httpc}"
277gleam_http = ">= 4.0.0 and < 5.0.0"
278envoy = "{envoy}""#
279            )
280        }
281    };
282
283    format!(
284        r#"name = "e2e_gleam"
285version = "0.1.0"
286target = "erlang"
287
288[dependencies]
289{deps}
290"#
291    )
292}
293
294#[allow(clippy::too_many_arguments)]
295fn render_test_file(
296    _category: &str,
297    fixtures: &[&Fixture],
298    e2e_config: &E2eConfig,
299    module_path: &str,
300    function_name: &str,
301    result_var: &str,
302    args: &[crate::config::ArgMapping],
303    field_resolver: &FieldResolver,
304    enum_fields: &HashSet<String>,
305    element_constructors: &[alef_core::config::GleamElementConstructor],
306    json_object_wrapper: Option<&str>,
307) -> String {
308    let mut out = String::new();
309    out.push_str(&hash::header(CommentStyle::DoubleSlash));
310    let _ = writeln!(out, "import gleeunit");
311    let _ = writeln!(out, "import gleeunit/should");
312
313    // Check if any fixture is HTTP-based.
314    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
315
316    // Import HTTP client for HTTP fixtures.
317    if has_http_fixtures {
318        let _ = writeln!(out, "import gleam/httpc");
319        let _ = writeln!(out, "import gleam/http");
320        let _ = writeln!(out, "import gleam/http/request");
321        let _ = writeln!(out, "import gleam/list");
322        let _ = writeln!(out, "import gleam/result");
323        let _ = writeln!(out, "import gleam/string");
324        let _ = writeln!(out, "import envoy");
325    }
326
327    // Import the call config module only if there are non-HTTP fixtures with overrides.
328    let has_non_http_with_override = fixtures.iter().any(|f| !f.is_http_test());
329    if has_non_http_with_override {
330        let _ = writeln!(out, "import {module_path}");
331        let _ = writeln!(out, "import e2e_gleam");
332        // mock_url args use envoy.get("MOCK_SERVER_URL") — need envoy import.
333        let needs_envoy_for_binding = !has_http_fixtures
334            && fixtures.iter().filter(|f| !f.is_http_test()).any(|f| {
335                let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
336                cc.args.iter().any(|a| a.arg_type == "mock_url")
337            });
338        if needs_envoy_for_binding {
339            let _ = writeln!(out, "import envoy");
340        }
341    }
342    let _ = writeln!(out);
343
344    // Track which modules we need to import based on assertions used (non-HTTP tests).
345    let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
346
347    // First pass: determine which helper modules we need.
348    for fixture in fixtures {
349        if fixture.is_http_test() {
350            continue; // Skip HTTP fixtures for assertion analysis.
351        }
352        // Determine if any args use `bytes` arg type — requires e2e_gleam file reader.
353        let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
354        let has_bytes_arg = call_config.args.iter().any(|a| a.arg_type == "bytes");
355        // Optional string args emit option.Some(...)/option.None — need option import.
356        let has_optional_string_arg = call_config.args.iter().any(|a| a.arg_type == "string" && a.optional);
357        // json_object args emit option.None in ExtractionConfig and BatchItem constructors.
358        let has_json_object_arg = call_config.args.iter().any(|a| a.arg_type == "json_object");
359        // handle args emit create_engine(option.None) — needs option import.
360        let has_handle_arg = call_config.args.iter().any(|a| a.arg_type == "handle");
361        if has_bytes_arg || has_optional_string_arg || has_json_object_arg || has_handle_arg {
362            needed_modules.insert("option");
363        }
364        for assertion in &fixture.assertions {
365            // When a field traverses a tagged-union variant, we emit a case expression
366            // that requires `option` for unwrapping the Option(FormatMetadata) wrapper.
367            let needs_case_expr = assertion
368                .field
369                .as_deref()
370                .is_some_and(|f| field_resolver.tagged_union_split(f).is_some());
371            if needs_case_expr {
372                needed_modules.insert("option");
373            }
374            // Optional field equality comparisons wrap in option.Some(...).
375            if let Some(f) = &assertion.field {
376                if field_resolver.is_optional(field_resolver.resolve(f)) {
377                    needed_modules.insert("option");
378                }
379            }
380            match assertion.assertion_type.as_str() {
381                "contains_any" => {
382                    // contains_any always generates list.any(...) + string.contains(...) — needs both.
383                    needed_modules.insert("string");
384                    needed_modules.insert("list");
385                }
386                "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" => {
387                    needed_modules.insert("string");
388                    // `contains` on an array field emits list.any — also need `list`.
389                    if let Some(f) = &assertion.field {
390                        let resolved = field_resolver.resolve(f);
391                        if field_resolver.is_array(f) || field_resolver.is_array(resolved) {
392                            needed_modules.insert("list");
393                        }
394                    } else {
395                        // No field → assertion on root result; if result_is_array, need list.
396                        if call_config.result_is_array
397                            || call_config.result_is_vec
398                            || field_resolver.is_array("")
399                            || field_resolver.is_array(field_resolver.resolve(""))
400                        {
401                            needed_modules.insert("list");
402                        }
403                    }
404                }
405                "not_empty" | "is_empty" => {
406                    // Array fields use list.is_empty, non-array non-optional fields use string.is_empty.
407                    if let Some(f) = &assertion.field {
408                        let resolved = field_resolver.resolve(f);
409                        let is_opt = field_resolver.is_optional(resolved);
410                        let is_arr = field_resolver.is_array(f) || field_resolver.is_array(resolved);
411                        if is_arr {
412                            needed_modules.insert("list");
413                        } else if is_opt {
414                            needed_modules.insert("option");
415                        } else {
416                            needed_modules.insert("string");
417                        }
418                    } else {
419                        needed_modules.insert("list");
420                    }
421                }
422                "count_min" | "count_equals" => {
423                    needed_modules.insert("list");
424                    // Note: count_min/count_equals use fn(n__) { n__ >= N } — no gleam/int import needed.
425                }
426                "min_length" | "max_length" => {
427                    needed_modules.insert("string");
428                    // Note: min_length/max_length use fn(n__) { n__ >= N } — no gleam/int import needed.
429                }
430                "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
431                    // Uses fn(n__) { n__ >= N } inline — no gleam/int import needed.
432                }
433                _ => {}
434            }
435            // When an array field is accessed inside a tagged-union case block, list is needed.
436            if needs_case_expr {
437                if let Some(f) = &assertion.field {
438                    let resolved = field_resolver.resolve(f);
439                    if field_resolver.is_array(resolved) {
440                        needed_modules.insert("list");
441                    }
442                }
443            }
444            // `.length` segment in a field path generates `list.length(...)` in Gleam.
445            if let Some(f) = &assertion.field {
446                if f.split('.').any(|seg| seg == "length") {
447                    needed_modules.insert("list");
448                }
449            }
450            // When an assertion uses optional-prefix patterns (e.g. document.nodes),
451            // both list and int (and option) may be needed.
452            if let Some(f) = &assertion.field {
453                if !f.is_empty() {
454                    let parts: Vec<&str> = f.split('.').collect();
455                    let has_opt_prefix = (1..parts.len()).any(|i| {
456                        let prefix_path = parts[..i].join(".");
457                        field_resolver.is_optional(&prefix_path)
458                    });
459                    if has_opt_prefix {
460                        needed_modules.insert("option");
461                        // For not_empty/is_empty on optional-prefix fields, the import
462                        // depends on whether the field is a list or a string.
463                        if matches!(assertion.assertion_type.as_str(), "not_empty" | "is_empty") {
464                            let resolved = field_resolver.resolve(f);
465                            if field_resolver.is_array(f) || field_resolver.is_array(resolved) {
466                                needed_modules.insert("list");
467                            } else {
468                                needed_modules.insert("string");
469                            }
470                        }
471                    }
472                }
473            }
474        }
475    }
476
477    // Emit additional imports.
478    for module in &needed_modules {
479        let _ = writeln!(out, "import gleam/{module}");
480    }
481
482    if !needed_modules.is_empty() {
483        let _ = writeln!(out);
484    }
485
486    // Each fixture becomes its own test function.
487    for fixture in fixtures {
488        if fixture.is_http_test() {
489            render_http_test_case(&mut out, fixture);
490        } else {
491            render_test_case(
492                &mut out,
493                fixture,
494                e2e_config,
495                module_path,
496                function_name,
497                result_var,
498                args,
499                field_resolver,
500                enum_fields,
501                element_constructors,
502                json_object_wrapper,
503            );
504        }
505        let _ = writeln!(out);
506    }
507
508    out
509}
510
511/// Gleam HTTP test renderer using `gleam_httpc` against `MOCK_SERVER_URL`.
512///
513/// Satisfies [`client::TestClientRenderer`] so the shared
514/// [`client::http_call::render_http_test`] driver drives the call sequence.
515struct GleamTestClientRenderer;
516
517impl client::TestClientRenderer for GleamTestClientRenderer {
518    fn language_name(&self) -> &'static str {
519        "gleam"
520    }
521
522    /// Gleam identifiers must start with a lowercase letter, not `_` or a digit.
523    /// Strip leading underscores/digits that result from numeric-prefixed fixture IDs
524    /// (e.g. `19_413_payload_too_large` → strip → `payload_too_large`), then
525    /// append `_test` as required by gleeunit's test-discovery convention.
526    fn sanitize_test_name(&self, id: &str) -> String {
527        let raw = sanitize_ident(id);
528        let stripped = raw.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
529        if stripped.is_empty() { raw } else { stripped.to_string() }
530    }
531
532    /// Emit `// {description}\npub fn {fn_name}_test() {`.
533    ///
534    /// gleeunit discovers tests as top-level `pub fn <name>_test()` functions.
535    /// Skipped fixtures get an immediate `todo` expression inside the body so the
536    /// suite still compiles; the shared driver calls `render_test_close` right after.
537    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
538        let _ = writeln!(out, "// {description}");
539        let _ = writeln!(out, "pub fn {fn_name}_test() {{");
540        if let Some(reason) = skip_reason {
541            // Gleam has no built-in skip mechanism; emit a comment + immediate return
542            // so the test compiles but is visually marked as skipped.
543            let escaped = escape_gleam(reason);
544            let _ = writeln!(out, "  // skipped: {escaped}");
545            let _ = writeln!(out, "  Nil");
546        }
547    }
548
549    /// Emit the closing `}` for the test function.
550    fn render_test_close(&self, out: &mut String) {
551        let _ = writeln!(out, "}}");
552    }
553
554    /// Emit a `gleam_httpc` request to `MOCK_SERVER_URL` + `ctx.path`.
555    ///
556    /// Uses `envoy.get` to read the base URL at runtime, builds the request with
557    /// `gleam/http/request`, sets method, headers, cookies, and body, then sends
558    /// it with `httpc.send`.  The response is bound to `ctx.response_var` (`resp`).
559    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
560        let path = ctx.path;
561
562        // Read base URL from environment.
563        let _ = writeln!(out, "  let base_url = case envoy.get(\"MOCK_SERVER_URL\") {{");
564        let _ = writeln!(out, "    Ok(u) -> u");
565        let _ = writeln!(out, "    Error(_) -> \"http://localhost:8080\"");
566        let _ = writeln!(out, "  }}");
567
568        // Build the request struct from the URL.
569        let _ = writeln!(out, "  let assert Ok(req) = request.to(base_url <> \"{path}\")");
570
571        // Set HTTP method.
572        let method_const = match ctx.method.to_uppercase().as_str() {
573            "GET" => "Get",
574            "POST" => "Post",
575            "PUT" => "Put",
576            "DELETE" => "Delete",
577            "PATCH" => "Patch",
578            "HEAD" => "Head",
579            "OPTIONS" => "Options",
580            _ => "Post",
581        };
582        let _ = writeln!(out, "  let req = request.set_method(req, http.{method_const})");
583
584        // Set Content-Type when a body is present.
585        if ctx.body.is_some() {
586            let content_type = ctx.content_type.unwrap_or("application/json");
587            let escaped_ct = escape_gleam(content_type);
588            let _ = writeln!(
589                out,
590                "  let req = request.set_header(req, \"content-type\", \"{escaped_ct}\")"
591            );
592        }
593
594        // Set additional request headers.
595        for (name, value) in ctx.headers {
596            let lower = name.to_lowercase();
597            if matches!(lower.as_str(), "content-length" | "host" | "transfer-encoding") {
598                continue;
599            }
600            let escaped_name = escape_gleam(name);
601            let escaped_value = escape_gleam(value);
602            let _ = writeln!(
603                out,
604                "  let req = request.set_header(req, \"{escaped_name}\", \"{escaped_value}\")"
605            );
606        }
607
608        // Merge cookies into a single `Cookie` header.
609        if !ctx.cookies.is_empty() {
610            let cookie_str: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
611            let escaped_cookie = escape_gleam(&cookie_str.join("; "));
612            let _ = writeln!(
613                out,
614                "  let req = request.set_header(req, \"cookie\", \"{escaped_cookie}\")"
615            );
616        }
617
618        // Set body when present.
619        if let Some(body) = ctx.body {
620            let json_str = serde_json::to_string(body).unwrap_or_default();
621            let escaped = escape_gleam(&json_str);
622            let _ = writeln!(out, "  let req = request.set_body(req, \"{escaped}\")");
623        }
624
625        // Send the request; bind the response.
626        let resp = ctx.response_var;
627        let _ = writeln!(out, "  let assert Ok({resp}) = httpc.send(req)");
628    }
629
630    /// Emit `resp.status |> should.equal(status)`.
631    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
632        let _ = writeln!(out, "  {response_var}.status |> should.equal({status})");
633    }
634
635    /// Emit a header presence check via `list.find`.
636    ///
637    /// The special tokens `<<present>>`, `<<absent>>`, and `<<uuid>>` are handled
638    /// as presence/absence checks since `gleam_httpc` returns headers as a list of
639    /// tuples and there is no stdlib regex in the Gleam standard library.
640    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
641        let escaped_name = escape_gleam(&name.to_lowercase());
642        match expected {
643            "<<absent>>" => {
644                let _ = writeln!(
645                    out,
646                    "  {response_var}.headers\n    |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n    |> result.is_ok()\n    |> should.be_false()"
647                );
648            }
649            "<<present>>" | "<<uuid>>" => {
650                // uuid token: check for presence only (no stdlib regex available).
651                let _ = writeln!(
652                    out,
653                    "  {response_var}.headers\n    |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n    |> result.is_ok()\n    |> should.be_true()"
654                );
655            }
656            literal => {
657                // For exact values, verify the header is present (value matching
658                // requires a custom find; presence is the meaningful assertion here).
659                let _escaped_value = escape_gleam(literal);
660                let _ = writeln!(
661                    out,
662                    "  {response_var}.headers\n    |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n    |> result.is_ok()\n    |> should.be_true()"
663                );
664            }
665        }
666    }
667
668    /// Emit `resp.body |> string.trim |> should.equal("...")`.
669    ///
670    /// Both structured (object/array) and primitive JSON values are serialised
671    /// to a JSON string and compared as raw text since `gleam_httpc` returns the
672    /// body as a `String`.
673    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
674        let escaped = match expected {
675            serde_json::Value::String(s) => escape_gleam(s),
676            other => escape_gleam(&serde_json::to_string(other).unwrap_or_default()),
677        };
678        let _ = writeln!(
679            out,
680            "  {response_var}.body |> string.trim |> should.equal(\"{escaped}\")"
681        );
682    }
683
684    /// Emit partial body assertions.
685    ///
686    /// `gleam_httpc` returns the body as a plain `String`; there is no stdlib JSON
687    /// parser in Gleam's standard library. A `string.contains` check per
688    /// key/value pair is the closest practical approximation.
689    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
690        if let Some(obj) = expected.as_object() {
691            for (key, val) in obj {
692                let fragment = escape_gleam(&format!("\"{}\":", key));
693                let _ = writeln!(
694                    out,
695                    "  {response_var}.body |> string.contains(\"{fragment}\") |> should.equal(True)"
696                );
697                let _ = val; // value-level matching requires a JSON library not in stdlib
698            }
699        }
700    }
701
702    /// Emit validation-error assertions by checking the raw body string for each
703    /// expected error message.
704    ///
705    /// `gleam_httpc` returns the body as a `String`; without a stdlib JSON decoder
706    /// the most reliable check is `string.contains` on the serialised message.
707    fn render_assert_validation_errors(
708        &self,
709        out: &mut String,
710        response_var: &str,
711        errors: &[ValidationErrorExpectation],
712    ) {
713        for err in errors {
714            let escaped_msg = escape_gleam(&err.msg);
715            let _ = writeln!(
716                out,
717                "  {response_var}.body |> string.contains(\"{escaped_msg}\") |> should.equal(True)"
718            );
719        }
720    }
721}
722
723/// Render an HTTP server test using `gleam_httpc` against `MOCK_SERVER_URL`.
724///
725/// Delegates to [`client::http_call::render_http_test`] via the shared driver.
726/// The WebSocket-upgrade filter (HTTP 101) is applied upstream in [`GleamE2eCodegen::generate`]
727/// before fixtures reach this function, so no pre-hook is needed here.
728fn render_http_test_case(out: &mut String, fixture: &Fixture) {
729    client::http_call::render_http_test(out, &GleamTestClientRenderer, fixture);
730}
731
732#[allow(clippy::too_many_arguments)]
733fn render_test_case(
734    out: &mut String,
735    fixture: &Fixture,
736    e2e_config: &E2eConfig,
737    module_path: &str,
738    _function_name: &str,
739    _result_var: &str,
740    _args: &[crate::config::ArgMapping],
741    field_resolver: &FieldResolver,
742    enum_fields: &HashSet<String>,
743    element_constructors: &[alef_core::config::GleamElementConstructor],
744    json_object_wrapper: Option<&str>,
745) {
746    // Resolve per-fixture call config.
747    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
748    let lang = "gleam";
749    let call_overrides = call_config.overrides.get(lang);
750    let function_name = call_overrides
751        .and_then(|o| o.function.as_ref())
752        .cloned()
753        .unwrap_or_else(|| call_config.function.clone());
754    let result_var = &call_config.result_var;
755    let args = &call_config.args;
756
757    // Gleam identifiers must start with a lowercase letter, not `_` or a digit.
758    // Strip any leading underscores or digits that result from numeric-prefixed fixture IDs
759    // (e.g. fixture id "19_413_payload_too_large" → "413_payload_too_large" →
760    // strip leading digits → "payload_too_large").
761    let raw_name = sanitize_ident(&fixture.id);
762    let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
763    let test_name = if stripped.is_empty() {
764        raw_name.as_str()
765    } else {
766        stripped
767    };
768    let description = &fixture.description;
769    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
770
771    let test_documents_path = e2e_config.test_documents_relative_from(0);
772    let (setup_lines, args_str) = build_args_and_setup(
773        &fixture.input,
774        args,
775        &fixture.id,
776        &test_documents_path,
777        element_constructors,
778        json_object_wrapper,
779        module_path,
780    );
781
782    // gleeunit discovers tests as top-level `pub fn <name>_test()` functions —
783    // emit one function per fixture so failures point at the offending fixture.
784    let _ = writeln!(out, "// {description}");
785    let _ = writeln!(out, "pub fn {test_name}_test() {{");
786
787    for line in &setup_lines {
788        let _ = writeln!(out, "  {line}");
789    }
790
791    if expects_error {
792        let _ = writeln!(out, "  {module_path}.{function_name}({args_str}) |> should.be_error()");
793        let _ = writeln!(out, "}}");
794        return;
795    }
796
797    let _ = writeln!(out, "  let {result_var} = {module_path}.{function_name}({args_str})");
798    let _ = writeln!(out, "  {result_var} |> should.be_ok()");
799    let _ = writeln!(out, "  let assert Ok(r) = {result_var}");
800
801    let result_is_array = call_config.result_is_array || call_config.result_is_vec;
802    // Tagged-union assertions need the package module qualifier for variant
803    // pattern matches. Resolve from `[e2e.packages.gleam] name`, falling back
804    // to the snake-cased crate name (matching the gleam.toml dependency name
805    // emitted earlier in this generator).
806    let pkg_module = e2e_config
807        .resolve_package("gleam")
808        .as_ref()
809        .and_then(|p| p.name.as_ref())
810        .cloned()
811        .unwrap_or_else(|| module_path.split('.').next().unwrap_or(module_path).to_string());
812    for assertion in &fixture.assertions {
813        render_assertion(
814            out,
815            assertion,
816            "r",
817            field_resolver,
818            enum_fields,
819            result_is_array,
820            &pkg_module,
821        );
822    }
823
824    let _ = writeln!(out, "}}");
825}
826
827/// Build setup lines and the argument list for the function call.
828///
829/// Gleam is statically typed, so each arg type must produce a correctly-typed expression:
830/// - `file_path` → quoted string literal
831/// - `bytes` → setup: `let assert Ok(data__) = e2e_gleam.read_file_bytes(...)` and arg: `data__`
832/// - `string` + optional → `option.Some("value")` or `option.None`
833/// - `string` non-optional → `"value"`
834/// - `json_object` (any shape) → JSON-string literal via `json_to_gleam`. Downstreams whose Gleam
835///   binding accepts a structured record (e.g. `<pkg>.Config(...)`) instead of a JSON string
836///   need to provide their own conversion layer outside alef.
837fn build_args_and_setup(
838    input: &serde_json::Value,
839    args: &[crate::config::ArgMapping],
840    fixture_id: &str,
841    test_documents_path: &str,
842    element_constructors: &[alef_core::config::GleamElementConstructor],
843    json_object_wrapper: Option<&str>,
844    module_path: &str,
845) -> (Vec<String>, String) {
846    if args.is_empty() {
847        return (Vec::new(), String::new());
848    }
849
850    let mut setup_lines: Vec<String> = Vec::new();
851    let mut parts: Vec<String> = Vec::new();
852    let mut bytes_var_counter = 0usize;
853
854    for arg in args {
855        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
856        let val = input.get(field);
857
858        match arg.arg_type.as_str() {
859            "handle" => {
860                // Engine construction: create_engine(option.None).
861                // Config construction from JSON is complex in Gleam (no JSON string constructor),
862                // so we always pass option.None — default engine config covers most test cases.
863                let name = &arg.name;
864                let constructor = format!("create_{}", name.to_snake_case());
865                setup_lines.push(format!(
866                    "let assert Ok({name}) = {module_path}.{constructor}(option.None)"
867                ));
868                parts.push(name.clone());
869                continue;
870            }
871            "mock_url" => {
872                // Resolve the mock server base URL at runtime via envoy, then append the fixture path.
873                let name = &arg.name;
874                setup_lines.push(format!(
875                    "let {name} = case envoy.get(\"MOCK_SERVER_URL\") {{ Ok(base) -> base <> \"/fixtures/{fixture_id}\" Error(_) -> \"http://localhost:8080/fixtures/{fixture_id}\" }}"
876                ));
877                parts.push(name.clone());
878                continue;
879            }
880            "file_path" => {
881                // Always a required string path.
882                // Gleam e2e runs from e2e/gleam/ so the path resolves relative
883                // to the configured test-documents directory.
884                let path = val.and_then(|v| v.as_str()).unwrap_or("");
885                let full_path = format!("{test_documents_path}/{path}");
886                parts.push(format!("\"{}\"", escape_gleam(&full_path)));
887            }
888            "bytes" => {
889                // Read the file at runtime via Erlang file:read_file/1.
890                // The fixture `data` field holds the path relative to the
891                // configured test-documents directory.
892                let path = val.and_then(|v| v.as_str()).unwrap_or("");
893                let var_name = if bytes_var_counter == 0 {
894                    "data_bytes__".to_string()
895                } else {
896                    format!("data_bytes_{bytes_var_counter}__")
897                };
898                bytes_var_counter += 1;
899                // Use relative path from e2e/gleam/ project root.
900                let full_path = format!("{test_documents_path}/{path}");
901                setup_lines.push(format!(
902                    "let assert Ok({var_name}) = e2e_gleam.read_file_bytes(\"{}\")",
903                    escape_gleam(&full_path)
904                ));
905                parts.push(var_name);
906            }
907            "string" if arg.optional => {
908                // Optional string: emit option.Some("value") or option.None.
909                match val {
910                    None | Some(serde_json::Value::Null) => {
911                        parts.push("option.None".to_string());
912                    }
913                    Some(serde_json::Value::String(s)) if s.is_empty() => {
914                        parts.push("option.None".to_string());
915                    }
916                    Some(serde_json::Value::String(s)) => {
917                        parts.push(format!("option.Some(\"{}\")", escape_gleam(s)));
918                    }
919                    Some(v) => {
920                        parts.push(format!("option.Some({})", json_to_gleam(v)));
921                    }
922                }
923            }
924            "string" => {
925                // Non-optional string.
926                match val {
927                    None | Some(serde_json::Value::Null) => {
928                        parts.push("\"\"".to_string());
929                    }
930                    Some(serde_json::Value::String(s)) => {
931                        parts.push(format!("\"{}\"", escape_gleam(s)));
932                    }
933                    Some(v) => {
934                        parts.push(json_to_gleam(v));
935                    }
936                }
937            }
938            "json_object" => {
939                // Look up a per-`element_type` constructor recipe declared in
940                // `[crates.gleam.element_constructors]`. When present, build a
941                // record literal from the recipe; otherwise fall back to a
942                // generic JSON-string emission via `json_to_gleam`.
943                let element_type = arg.element_type.as_deref().unwrap_or("");
944                let recipe = if element_type.is_empty() {
945                    None
946                } else {
947                    element_constructors.iter().find(|r| r.element_type == element_type)
948                };
949
950                if let Some(recipe) = recipe {
951                    // List-of-records emission: each JSON-array item becomes
952                    // one constructor call; non-array values produce an empty
953                    // list (preserving the iter15 behaviour).
954                    let items_expr = match val {
955                        Some(serde_json::Value::Array(arr)) => {
956                            let items: Vec<String> = arr
957                                .iter()
958                                .map(|item| render_gleam_element_constructor(item, recipe, test_documents_path))
959                                .collect();
960                            format!("[{}]", items.join(", "))
961                        }
962                        _ => "[]".to_string(),
963                    };
964                    if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
965                        parts.push("[]".to_string());
966                    } else {
967                        parts.push(items_expr);
968                    }
969                } else if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
970                    parts.push("option.None".to_string());
971                } else {
972                    let empty_obj = serde_json::Value::Object(Default::default());
973                    let config_val = val.unwrap_or(&empty_obj);
974                    let json_literal = json_to_gleam(config_val);
975                    // When the downstream has configured a wrapper (e.g.
976                    // `kreuzberg.config_from_json_string({json})`), substitute
977                    // the placeholder; otherwise emit the bare JSON-string
978                    // literal.
979                    let emitted = match json_object_wrapper {
980                        Some(template) => template.replace("{json}", &json_literal),
981                        None => json_literal,
982                    };
983                    parts.push(emitted);
984                }
985            }
986            "int" | "integer" => match val {
987                None | Some(serde_json::Value::Null) if arg.optional => {}
988                None | Some(serde_json::Value::Null) => parts.push("0".to_string()),
989                Some(v) => parts.push(json_to_gleam(v)),
990            },
991            "bool" | "boolean" => match val {
992                Some(serde_json::Value::Bool(true)) => parts.push("True".to_string()),
993                Some(serde_json::Value::Bool(false)) | None | Some(serde_json::Value::Null) => {
994                    if !arg.optional {
995                        parts.push("False".to_string());
996                    }
997                }
998                Some(v) => parts.push(json_to_gleam(v)),
999            },
1000            _ => {
1001                // Fallback for unknown types.
1002                match val {
1003                    None | Some(serde_json::Value::Null) if arg.optional => {}
1004                    None | Some(serde_json::Value::Null) => parts.push("Nil".to_string()),
1005                    Some(v) => parts.push(json_to_gleam(v)),
1006                }
1007            }
1008        }
1009    }
1010
1011    (setup_lines, parts.join(", "))
1012}
1013
1014/// Render a single Gleam record-constructor call for one item of a
1015/// `json_object` list arg, driven by a `[crates.gleam.element_constructors]`
1016/// entry. Each field is dispatched by its `kind`:
1017///
1018/// * `file_path` — emits a Gleam string literal; relative paths are prefixed
1019///   with `test_documents_path` so they resolve from the e2e working dir.
1020/// * `byte_array` — emits a Gleam BitArray literal `<<n1, n2, ...>>` from a
1021///   JSON array of unsigned integers.
1022/// * `string` — emits a Gleam string literal; missing/null falls back to
1023///   the field's `default` (or `""`).
1024/// * `literal` — emits the field's `value` verbatim.
1025fn render_gleam_element_constructor(
1026    item: &serde_json::Value,
1027    recipe: &alef_core::config::GleamElementConstructor,
1028    test_documents_path: &str,
1029) -> String {
1030    let mut field_exprs: Vec<String> = Vec::with_capacity(recipe.fields.len());
1031    for field in &recipe.fields {
1032        let expr = match field.kind.as_str() {
1033            "file_path" => {
1034                let json_field = field.json_field.as_deref().unwrap_or("");
1035                let path = item.get(json_field).and_then(|v| v.as_str()).unwrap_or("");
1036                let full = if path.starts_with('/') {
1037                    path.to_string()
1038                } else {
1039                    format!("{test_documents_path}/{path}")
1040                };
1041                format!("\"{}\"", escape_gleam(&full))
1042            }
1043            "byte_array" => {
1044                let json_field = field.json_field.as_deref().unwrap_or("");
1045                let bytes: Vec<String> = item
1046                    .get(json_field)
1047                    .and_then(|v| v.as_array())
1048                    .map(|arr| arr.iter().map(|b| b.as_u64().unwrap_or(0).to_string()).collect())
1049                    .unwrap_or_default();
1050                if bytes.is_empty() {
1051                    "<<>>".to_string()
1052                } else {
1053                    format!("<<{}>>", bytes.join(", "))
1054                }
1055            }
1056            "string" => {
1057                let json_field = field.json_field.as_deref().unwrap_or("");
1058                let value = item
1059                    .get(json_field)
1060                    .and_then(|v| v.as_str())
1061                    .map(str::to_string)
1062                    .or_else(|| field.default.clone())
1063                    .unwrap_or_default();
1064                format!("\"{}\"", escape_gleam(&value))
1065            }
1066            "literal" => field.value.clone().unwrap_or_default(),
1067            other => {
1068                // Unknown kind — fall back to a verbatim literal of the value
1069                // field if present, else an empty string. Surfacing the
1070                // unsupported kind in the generated code makes the error
1071                // visible at compile-time rather than failing silently.
1072                field
1073                    .value
1074                    .clone()
1075                    .unwrap_or_else(|| format!("\"<unsupported kind: {other}>\""))
1076            }
1077        };
1078        field_exprs.push(format!("{}: {}", field.gleam_field, expr));
1079    }
1080    format!("{}({})", recipe.constructor, field_exprs.join(", "))
1081}
1082
1083/// Render an assertion for a field that traverses a tagged-union variant.
1084///
1085/// Gleam tagged unions (sum types) require `case` pattern matching — you
1086/// cannot access a variant's fields via dot syntax on the union type itself.
1087///
1088/// `pkg_module` is the downstream binding's Gleam module name (resolved from
1089/// `[e2e.packages.gleam] name` by the caller); it is interpolated as the
1090/// qualifier on the variant constructor.
1091#[allow(clippy::too_many_arguments)]
1092fn render_tagged_union_assertion(
1093    out: &mut String,
1094    assertion: &Assertion,
1095    result_var: &str,
1096    prefix: &str,
1097    variant: &str,
1098    suffix: &str,
1099    field_resolver: &FieldResolver,
1100    pkg_module: &str,
1101) {
1102    // Build the accessor for the field up to (but not including) the variant.
1103    // e.g. prefix="metadata.format" → r.metadata.format
1104    let prefix_expr = if prefix.is_empty() {
1105        result_var.to_string()
1106    } else {
1107        format!("{result_var}.{prefix}")
1108    };
1109
1110    // Gleam constructor name is PascalCase of the variant.
1111    // e.g. "excel" → "Excel", "email" → "FormatMetadataEmail" etc.
1112    // The package module is emitted as the module qualifier.
1113    let constructor = variant.to_pascal_case();
1114    // The downstream binding's Gleam module — used as the qualifier for the
1115    // tagged-union constructor (e.g. `<pkg>.Excel(inner)`). Resolved by the
1116    // caller from `[e2e.packages.gleam] name`.
1117    let module_qualifier = pkg_module;
1118
1119    // The inner variable bound to the variant payload.
1120    let inner_var = "fmt_inner__";
1121
1122    // Determine whether the suffix field is optional or an array.
1123    // The resolved full path for the suffix is `{prefix}.{variant}.{suffix}`.
1124    let full_suffix_path = if prefix.is_empty() {
1125        format!("{variant}.{suffix}")
1126    } else {
1127        format!("{prefix}.{variant}.{suffix}")
1128    };
1129    let suffix_is_optional = field_resolver.is_optional(&full_suffix_path);
1130    let suffix_is_array = field_resolver.is_array(&full_suffix_path);
1131
1132    // Open the case block.
1133    let _ = writeln!(out, "  case {prefix_expr} {{");
1134    let _ = writeln!(
1135        out,
1136        "    option.Some({module_qualifier}.{constructor}({inner_var})) -> {{"
1137    );
1138
1139    // Build the inner field expression.
1140    let inner_field_expr = if suffix.is_empty() {
1141        inner_var.to_string()
1142    } else {
1143        format!("{inner_var}.{suffix}")
1144    };
1145
1146    // Emit the assertion body inside the Some branch.
1147    match assertion.assertion_type.as_str() {
1148        "equals" => {
1149            if let Some(expected) = &assertion.value {
1150                let gleam_val = json_to_gleam(expected);
1151                if suffix_is_optional {
1152                    let default = default_gleam_value_for_optional(&gleam_val);
1153                    let _ = writeln!(
1154                        out,
1155                        "      {inner_field_expr} |> option.unwrap({default}) |> should.equal({gleam_val})"
1156                    );
1157                } else {
1158                    let _ = writeln!(out, "      {inner_field_expr} |> should.equal({gleam_val})");
1159                }
1160            }
1161        }
1162        "contains" => {
1163            if let Some(expected) = &assertion.value {
1164                let gleam_val = json_to_gleam(expected);
1165                if suffix_is_array {
1166                    // List of strings: check any element contains the value.
1167                    let _ = writeln!(out, "      let items__ = {inner_field_expr} |> option.unwrap([])");
1168                    let _ = writeln!(
1169                        out,
1170                        "      items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1171                    );
1172                } else if suffix_is_optional {
1173                    let _ = writeln!(
1174                        out,
1175                        "      {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1176                    );
1177                } else {
1178                    let _ = writeln!(
1179                        out,
1180                        "      {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1181                    );
1182                }
1183            }
1184        }
1185        "contains_all" => {
1186            if let Some(values) = &assertion.values {
1187                if suffix_is_array {
1188                    // List of strings: for each expected value, check any element contains it.
1189                    let _ = writeln!(out, "      let items__ = {inner_field_expr} |> option.unwrap([])");
1190                    for val in values {
1191                        let gleam_val = json_to_gleam(val);
1192                        let _ = writeln!(
1193                            out,
1194                            "      items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1195                        );
1196                    }
1197                } else if suffix_is_optional {
1198                    for val in values {
1199                        let gleam_val = json_to_gleam(val);
1200                        let _ = writeln!(
1201                            out,
1202                            "      {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1203                        );
1204                    }
1205                } else {
1206                    for val in values {
1207                        let gleam_val = json_to_gleam(val);
1208                        let _ = writeln!(
1209                            out,
1210                            "      {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1211                        );
1212                    }
1213                }
1214            }
1215        }
1216        "greater_than_or_equal" => {
1217            if let Some(val) = &assertion.value {
1218                let gleam_val = json_to_gleam(val);
1219                if suffix_is_optional {
1220                    let _ = writeln!(
1221                        out,
1222                        "      {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1223                    );
1224                } else {
1225                    let _ = writeln!(
1226                        out,
1227                        "      {inner_field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1228                    );
1229                }
1230            }
1231        }
1232        "greater_than" => {
1233            if let Some(val) = &assertion.value {
1234                let gleam_val = json_to_gleam(val);
1235                if suffix_is_optional {
1236                    let _ = writeln!(
1237                        out,
1238                        "      {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1239                    );
1240                } else {
1241                    let _ = writeln!(
1242                        out,
1243                        "      {inner_field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1244                    );
1245                }
1246            }
1247        }
1248        "less_than" => {
1249            if let Some(val) = &assertion.value {
1250                let gleam_val = json_to_gleam(val);
1251                if suffix_is_optional {
1252                    let _ = writeln!(
1253                        out,
1254                        "      {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1255                    );
1256                } else {
1257                    let _ = writeln!(
1258                        out,
1259                        "      {inner_field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1260                    );
1261                }
1262            }
1263        }
1264        "less_than_or_equal" => {
1265            if let Some(val) = &assertion.value {
1266                let gleam_val = json_to_gleam(val);
1267                if suffix_is_optional {
1268                    let _ = writeln!(
1269                        out,
1270                        "      {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1271                    );
1272                } else {
1273                    let _ = writeln!(
1274                        out,
1275                        "      {inner_field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1276                    );
1277                }
1278            }
1279        }
1280        "count_min" => {
1281            if let Some(val) = &assertion.value {
1282                if let Some(n) = val.as_u64() {
1283                    if suffix_is_optional {
1284                        let _ = writeln!(
1285                            out,
1286                            "      {inner_field_expr} |> option.unwrap([]) |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1287                        );
1288                    } else {
1289                        let _ = writeln!(
1290                            out,
1291                            "      {inner_field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1292                        );
1293                    }
1294                }
1295            }
1296        }
1297        "count_equals" => {
1298            if let Some(val) = &assertion.value {
1299                if let Some(n) = val.as_u64() {
1300                    if suffix_is_optional {
1301                        let _ = writeln!(
1302                            out,
1303                            "      {inner_field_expr} |> option.unwrap([]) |> list.length |> should.equal({n})"
1304                        );
1305                    } else {
1306                        let _ = writeln!(out, "      {inner_field_expr} |> list.length |> should.equal({n})");
1307                    }
1308                }
1309            }
1310        }
1311        "not_empty" => {
1312            if suffix_is_optional {
1313                let _ = writeln!(
1314                    out,
1315                    "      {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(False)"
1316                );
1317            } else if suffix_is_array {
1318                let _ = writeln!(out, "      {inner_field_expr} |> list.is_empty |> should.equal(False)");
1319            } else {
1320                let _ = writeln!(
1321                    out,
1322                    "      {inner_field_expr} |> string.is_empty |> should.equal(False)"
1323                );
1324            }
1325        }
1326        "is_empty" => {
1327            if suffix_is_optional {
1328                let _ = writeln!(
1329                    out,
1330                    "      {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(True)"
1331                );
1332            } else if suffix_is_array {
1333                let _ = writeln!(out, "      {inner_field_expr} |> list.is_empty |> should.equal(True)");
1334            } else {
1335                let _ = writeln!(out, "      {inner_field_expr} |> string.is_empty |> should.equal(True)");
1336            }
1337        }
1338        "is_true" => {
1339            let _ = writeln!(out, "      {inner_field_expr} |> should.equal(True)");
1340        }
1341        "is_false" => {
1342            let _ = writeln!(out, "      {inner_field_expr} |> should.equal(False)");
1343        }
1344        other => {
1345            let _ = writeln!(
1346                out,
1347                "      // tagged-union assertion '{other}' not yet implemented for Gleam"
1348            );
1349        }
1350    }
1351
1352    // Close the Some branch and add wildcard fallback.
1353    let _ = writeln!(out, "    }}");
1354    let _ = writeln!(
1355        out,
1356        "    _ -> panic as \"expected {module_qualifier}.{constructor} format metadata\""
1357    );
1358    let _ = writeln!(out, "  }}");
1359}
1360
1361/// Return a sensible Gleam default value for `option.unwrap(default)` based
1362/// on the type inferred from the JSON expected value string.
1363fn default_gleam_value_for_optional(gleam_val: &str) -> &'static str {
1364    if gleam_val.starts_with('"') {
1365        "\"\""
1366    } else if gleam_val == "True" || gleam_val == "False" {
1367        "False"
1368    } else if gleam_val.contains('.') {
1369        "0.0"
1370    } else {
1371        "0"
1372    }
1373}
1374
1375fn render_assertion(
1376    out: &mut String,
1377    assertion: &Assertion,
1378    result_var: &str,
1379    field_resolver: &FieldResolver,
1380    enum_fields: &HashSet<String>,
1381    result_is_array: bool,
1382    pkg_module: &str,
1383) {
1384    // Skip assertions on fields that don't exist on the result type.
1385    if let Some(f) = &assertion.field {
1386        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1387            let _ = writeln!(out, "  // skipped: field '{f}' not available on result type");
1388            return;
1389        }
1390    }
1391
1392    // Skip array-element field access — Gleam doesn't support list indexing.
1393    if let Some(f) = &assertion.field {
1394        if f.contains("[].") || f.contains("[0].") {
1395            let _ = writeln!(
1396                out,
1397                "  // skipped: array element field '{f}' not yet supported in Gleam e2e"
1398            );
1399            return;
1400        }
1401    }
1402
1403    // Detect tagged-union variant access (e.g., metadata.format.excel.sheet_count).
1404    // Gleam tagged unions are sum types — direct field access is not valid.
1405    // Instead, emit a case expression to pattern-match the variant.
1406    if let Some(f) = &assertion.field {
1407        if !f.is_empty() {
1408            if let Some((prefix, variant, suffix)) = field_resolver.tagged_union_split(f) {
1409                render_tagged_union_assertion(
1410                    out,
1411                    assertion,
1412                    result_var,
1413                    &prefix,
1414                    &variant,
1415                    &suffix,
1416                    field_resolver,
1417                    pkg_module,
1418                );
1419                return;
1420            }
1421        }
1422    }
1423
1424    // Detect field paths with an optional prefix segment (e.g. "document.nodes" where
1425    // "document" is Option(DocumentStructure)). These require a case expression to unwrap.
1426    if let Some(f) = &assertion.field {
1427        if !f.is_empty() {
1428            let parts: Vec<&str> = f.split('.').collect();
1429            let mut opt_prefix: Option<(String, usize)> = None;
1430            for i in 1..parts.len() {
1431                let prefix_path = parts[..i].join(".");
1432                if field_resolver.is_optional(&prefix_path) {
1433                    opt_prefix = Some((prefix_path, i));
1434                    break;
1435                }
1436            }
1437            if let Some((optional_prefix, suffix_start)) = opt_prefix {
1438                let prefix_expr = format!("{result_var}.{optional_prefix}");
1439                let suffix_parts = &parts[suffix_start..];
1440                let suffix_str = suffix_parts.join(".");
1441                let inner_var = "opt_inner__";
1442                let inner_expr = if suffix_str.is_empty() {
1443                    inner_var.to_string()
1444                } else {
1445                    format!("{inner_var}.{suffix_str}")
1446                };
1447                let _ = writeln!(out, "  case {prefix_expr} {{");
1448                let _ = writeln!(out, "    option.Some({inner_var}) -> {{");
1449                match assertion.assertion_type.as_str() {
1450                    "count_min" => {
1451                        if let Some(val) = &assertion.value {
1452                            if let Some(n) = val.as_u64() {
1453                                let _ = writeln!(
1454                                    out,
1455                                    "      {inner_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1456                                );
1457                            }
1458                        }
1459                    }
1460                    "count_equals" => {
1461                        if let Some(val) = &assertion.value {
1462                            if let Some(n) = val.as_u64() {
1463                                let _ = writeln!(out, "      {inner_expr} |> list.length |> should.equal({n})");
1464                            }
1465                        }
1466                    }
1467                    "not_empty" => {
1468                        let is_arr = field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f));
1469                        if is_arr {
1470                            let _ = writeln!(out, "      {inner_expr} |> list.is_empty |> should.equal(False)");
1471                        } else {
1472                            let _ = writeln!(out, "      {inner_expr} |> string.is_empty |> should.equal(False)");
1473                        }
1474                    }
1475                    "min_length" => {
1476                        if let Some(val) = &assertion.value {
1477                            if let Some(n) = val.as_u64() {
1478                                let _ = writeln!(
1479                                    out,
1480                                    "      {inner_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1481                                );
1482                            }
1483                        }
1484                    }
1485                    other => {
1486                        let _ = writeln!(
1487                            out,
1488                            "      // optional-prefix assertion '{other}' not yet implemented for Gleam"
1489                        );
1490                    }
1491                }
1492                let _ = writeln!(out, "    }}");
1493                let _ = writeln!(out, "    option.None -> should.fail()");
1494                let _ = writeln!(out, "  }}");
1495                return;
1496            }
1497        }
1498    }
1499
1500    // Determine if this field is an optional type (e.g. metadata.output_format).
1501    // For optional fields, equality comparisons must wrap in option.Some(...).
1502    let field_is_optional = assertion
1503        .field
1504        .as_deref()
1505        .is_some_and(|f| !f.is_empty() && field_resolver.is_optional(field_resolver.resolve(f)));
1506
1507    // Determine if this field is an enum type.
1508    let _field_is_enum = assertion
1509        .field
1510        .as_deref()
1511        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1512
1513    let field_expr = match &assertion.field {
1514        Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
1515        _ => result_var.to_string(),
1516    };
1517
1518    // Check if the field (or root result) is an array for `contains` assertions.
1519    // When no field is specified (root result) and call config says result_is_array, treat as array.
1520    let field_is_array = {
1521        let f = assertion.field.as_deref().unwrap_or("");
1522        let is_root = f.is_empty();
1523        (is_root && result_is_array) || field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f))
1524    };
1525
1526    match assertion.assertion_type.as_str() {
1527        "equals" => {
1528            if let Some(expected) = &assertion.value {
1529                let gleam_val = json_to_gleam(expected);
1530                if field_is_optional {
1531                    // Option(T) equality — wrap in option.Some().
1532                    let _ = writeln!(out, "  {field_expr} |> should.equal(option.Some({gleam_val}))");
1533                } else {
1534                    let _ = writeln!(out, "  {field_expr} |> should.equal({gleam_val})");
1535                }
1536            }
1537        }
1538        "contains" => {
1539            if let Some(expected) = &assertion.value {
1540                let gleam_val = json_to_gleam(expected);
1541                if field_is_array {
1542                    // List(String) — check any element contains the value.
1543                    let _ = writeln!(
1544                        out,
1545                        "  {field_expr} |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1546                    );
1547                } else if field_is_optional {
1548                    let _ = writeln!(
1549                        out,
1550                        "  {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1551                    );
1552                } else {
1553                    let _ = writeln!(
1554                        out,
1555                        "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1556                    );
1557                }
1558            }
1559        }
1560        "contains_all" => {
1561            if let Some(values) = &assertion.values {
1562                for val in values {
1563                    let gleam_val = json_to_gleam(val);
1564                    if field_is_optional {
1565                        let _ = writeln!(
1566                            out,
1567                            "  {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1568                        );
1569                    } else {
1570                        let _ = writeln!(
1571                            out,
1572                            "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1573                        );
1574                    }
1575                }
1576            }
1577        }
1578        "not_contains" => {
1579            if let Some(expected) = &assertion.value {
1580                let gleam_val = json_to_gleam(expected);
1581                let _ = writeln!(
1582                    out,
1583                    "  {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
1584                );
1585            }
1586        }
1587        "not_empty" => {
1588            if field_is_optional {
1589                // Option(T) — check it is Some.
1590                let _ = writeln!(out, "  {field_expr} |> option.is_some |> should.equal(True)");
1591            } else if field_is_array {
1592                let _ = writeln!(out, "  {field_expr} |> list.is_empty |> should.equal(False)");
1593            } else {
1594                let _ = writeln!(out, "  {field_expr} |> string.is_empty |> should.equal(False)");
1595            }
1596        }
1597        "is_empty" => {
1598            if field_is_optional {
1599                let _ = writeln!(out, "  {field_expr} |> option.is_none |> should.equal(True)");
1600            } else if field_is_array {
1601                let _ = writeln!(out, "  {field_expr} |> list.is_empty |> should.equal(True)");
1602            } else {
1603                let _ = writeln!(out, "  {field_expr} |> string.is_empty |> should.equal(True)");
1604            }
1605        }
1606        "starts_with" => {
1607            if let Some(expected) = &assertion.value {
1608                let gleam_val = json_to_gleam(expected);
1609                let _ = writeln!(
1610                    out,
1611                    "  {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
1612                );
1613            }
1614        }
1615        "ends_with" => {
1616            if let Some(expected) = &assertion.value {
1617                let gleam_val = json_to_gleam(expected);
1618                let _ = writeln!(
1619                    out,
1620                    "  {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
1621                );
1622            }
1623        }
1624        "min_length" => {
1625            if let Some(val) = &assertion.value {
1626                if let Some(n) = val.as_u64() {
1627                    let _ = writeln!(
1628                        out,
1629                        "  {field_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1630                    );
1631                }
1632            }
1633        }
1634        "max_length" => {
1635            if let Some(val) = &assertion.value {
1636                if let Some(n) = val.as_u64() {
1637                    let _ = writeln!(
1638                        out,
1639                        "  {field_expr} |> string.length |> fn(n__) {{ n__ <= {n} }} |> should.equal(True)"
1640                    );
1641                }
1642            }
1643        }
1644        "count_min" => {
1645            if let Some(val) = &assertion.value {
1646                if let Some(n) = val.as_u64() {
1647                    let _ = writeln!(
1648                        out,
1649                        "  {field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1650                    );
1651                }
1652            }
1653        }
1654        "count_equals" => {
1655            if let Some(val) = &assertion.value {
1656                if let Some(n) = val.as_u64() {
1657                    let _ = writeln!(out, "  {field_expr} |> list.length |> should.equal({n})");
1658                }
1659            }
1660        }
1661        "is_true" => {
1662            let _ = writeln!(out, "  {field_expr} |> should.equal(True)");
1663        }
1664        "is_false" => {
1665            let _ = writeln!(out, "  {field_expr} |> should.equal(False)");
1666        }
1667        "not_error" => {
1668            // Already handled by the call succeeding.
1669        }
1670        "error" => {
1671            // Handled at the test case level.
1672        }
1673        "greater_than" => {
1674            if let Some(val) = &assertion.value {
1675                let gleam_val = json_to_gleam(val);
1676                let _ = writeln!(
1677                    out,
1678                    "  {field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1679                );
1680            }
1681        }
1682        "less_than" => {
1683            if let Some(val) = &assertion.value {
1684                let gleam_val = json_to_gleam(val);
1685                let _ = writeln!(
1686                    out,
1687                    "  {field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1688                );
1689            }
1690        }
1691        "greater_than_or_equal" => {
1692            if let Some(val) = &assertion.value {
1693                let gleam_val = json_to_gleam(val);
1694                let _ = writeln!(
1695                    out,
1696                    "  {field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1697                );
1698            }
1699        }
1700        "less_than_or_equal" => {
1701            if let Some(val) = &assertion.value {
1702                let gleam_val = json_to_gleam(val);
1703                let _ = writeln!(
1704                    out,
1705                    "  {field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1706                );
1707            }
1708        }
1709        "contains_any" => {
1710            if let Some(values) = &assertion.values {
1711                let vals_list = values.iter().map(json_to_gleam).collect::<Vec<_>>().join(", ");
1712                let _ = writeln!(
1713                    out,
1714                    "  [{vals_list}] |> list.any(fn(v__) {{ string.contains({field_expr}, v__) }}) |> should.equal(True)"
1715                );
1716            }
1717        }
1718        "matches_regex" => {
1719            let _ = writeln!(out, "  // regex match not yet implemented for Gleam");
1720        }
1721        "method_result" => {
1722            let _ = writeln!(out, "  // method_result assertions not yet implemented for Gleam");
1723        }
1724        other => {
1725            panic!("Gleam e2e generator: unsupported assertion type: {other}");
1726        }
1727    }
1728}
1729
1730/// Convert a `serde_json::Value` to a Gleam literal string.
1731fn json_to_gleam(value: &serde_json::Value) -> String {
1732    match value {
1733        serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
1734        serde_json::Value::Bool(b) => {
1735            if *b {
1736                "True".to_string()
1737            } else {
1738                "False".to_string()
1739            }
1740        }
1741        serde_json::Value::Number(n) => n.to_string(),
1742        serde_json::Value::Null => "Nil".to_string(),
1743        serde_json::Value::Array(arr) => {
1744            let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
1745            format!("[{}]", items.join(", "))
1746        }
1747        serde_json::Value::Object(_) => {
1748            let json_str = serde_json::to_string(value).unwrap_or_default();
1749            format!("\"{}\"", escape_gleam(&json_str))
1750        }
1751    }
1752}
1753
1754#[cfg(test)]
1755mod tests {
1756    use super::*;
1757    use alef_core::config::{GleamElementConstructor, GleamElementField};
1758
1759    fn batch_file_item_recipe() -> GleamElementConstructor {
1760        GleamElementConstructor {
1761            element_type: "BatchFileItem".to_string(),
1762            constructor: "kreuzberg.BatchFileItem".to_string(),
1763            fields: vec![
1764                GleamElementField {
1765                    gleam_field: "path".to_string(),
1766                    kind: "file_path".to_string(),
1767                    json_field: Some("path".to_string()),
1768                    default: None,
1769                    value: None,
1770                },
1771                GleamElementField {
1772                    gleam_field: "config".to_string(),
1773                    kind: "literal".to_string(),
1774                    json_field: None,
1775                    default: None,
1776                    value: Some("option.None".to_string()),
1777                },
1778            ],
1779        }
1780    }
1781
1782    #[test]
1783    fn render_element_constructor_file_path_relative_path_gets_test_documents_prefix() {
1784        let item = serde_json::json!({ "path": "docx/fake.docx" });
1785        let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
1786        assert_eq!(
1787            out,
1788            "kreuzberg.BatchFileItem(path: \"../../test_documents/docx/fake.docx\", config: option.None)"
1789        );
1790    }
1791
1792    #[test]
1793    fn render_element_constructor_file_path_absolute_path_passes_through() {
1794        let item = serde_json::json!({ "path": "/etc/some/absolute" });
1795        let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
1796        assert!(
1797            out.contains("\"/etc/some/absolute\""),
1798            "absolute paths must NOT receive the test_documents prefix; got:\n{out}"
1799        );
1800    }
1801
1802    #[test]
1803    fn render_element_constructor_byte_array_emits_bitarray() {
1804        let recipe = GleamElementConstructor {
1805            element_type: "BatchBytesItem".to_string(),
1806            constructor: "kreuzberg.BatchBytesItem".to_string(),
1807            fields: vec![
1808                GleamElementField {
1809                    gleam_field: "content".to_string(),
1810                    kind: "byte_array".to_string(),
1811                    json_field: Some("content".to_string()),
1812                    default: None,
1813                    value: None,
1814                },
1815                GleamElementField {
1816                    gleam_field: "mime_type".to_string(),
1817                    kind: "string".to_string(),
1818                    json_field: Some("mime_type".to_string()),
1819                    default: Some("text/plain".to_string()),
1820                    value: None,
1821                },
1822                GleamElementField {
1823                    gleam_field: "config".to_string(),
1824                    kind: "literal".to_string(),
1825                    json_field: None,
1826                    default: None,
1827                    value: Some("option.None".to_string()),
1828                },
1829            ],
1830        };
1831        let item = serde_json::json!({ "content": [72, 105], "mime_type": "text/html" });
1832        let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
1833        assert_eq!(
1834            out,
1835            "kreuzberg.BatchBytesItem(content: <<72, 105>>, mime_type: \"text/html\", config: option.None)"
1836        );
1837    }
1838
1839    #[test]
1840    fn build_args_with_json_object_wrapper_substitutes_placeholder() {
1841        use crate::config::ArgMapping;
1842        let arg = ArgMapping {
1843            name: "config".to_string(),
1844            field: "config".to_string(),
1845            arg_type: "json_object".to_string(),
1846            optional: false,
1847            owned: false,
1848            element_type: None,
1849            go_type: None,
1850        };
1851        let input = serde_json::json!({
1852            "config": { "use_cache": true, "force_ocr": false }
1853        });
1854        let (_setup, args_str) = build_args_and_setup(
1855            &input,
1856            &[arg],
1857            "test_fixture",
1858            "../../test_documents",
1859            &[],
1860            Some("k.config_from_json_string({json})"),
1861            "kreuzberg",
1862        );
1863        // The wrapper template substitutes {json} with the JSON-string literal
1864        // emitted by json_to_gleam.
1865        assert!(
1866            args_str.starts_with("k.config_from_json_string("),
1867            "wrapper must envelop the JSON literal; got:\n{args_str}"
1868        );
1869        assert!(
1870            args_str.contains("use_cache"),
1871            "JSON payload must reach the wrapper; got:\n{args_str}"
1872        );
1873    }
1874
1875    #[test]
1876    fn build_args_without_json_object_wrapper_emits_bare_json_string() {
1877        use crate::config::ArgMapping;
1878        let arg = ArgMapping {
1879            name: "config".to_string(),
1880            field: "config".to_string(),
1881            arg_type: "json_object".to_string(),
1882            optional: false,
1883            owned: false,
1884            element_type: None,
1885            go_type: None,
1886        };
1887        let input = serde_json::json!({ "config": { "x": 1 } });
1888        let (_setup, args_str) = build_args_and_setup(
1889            &input,
1890            &[arg],
1891            "test_fixture",
1892            "../../test_documents",
1893            &[],
1894            None,
1895            "kreuzberg",
1896        );
1897        // Default behaviour: bare JSON-string literal, no wrapper. The
1898        // emission must NOT contain any function-call shape from a wrapper.
1899        assert!(
1900            !args_str.contains("from_json_string"),
1901            "no wrapper configured must not synthesise one; got:\n{args_str}"
1902        );
1903        assert!(
1904            args_str.starts_with('"'),
1905            "bare emission is a Gleam string literal starting with a quote; got:\n{args_str}"
1906        );
1907    }
1908
1909    #[test]
1910    fn render_element_constructor_string_falls_back_to_default() {
1911        let recipe = GleamElementConstructor {
1912            element_type: "BatchBytesItem".to_string(),
1913            constructor: "k.BatchBytesItem".to_string(),
1914            fields: vec![GleamElementField {
1915                gleam_field: "mime_type".to_string(),
1916                kind: "string".to_string(),
1917                json_field: Some("mime_type".to_string()),
1918                default: Some("text/plain".to_string()),
1919                value: None,
1920            }],
1921        };
1922        let item = serde_json::json!({});
1923        let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
1924        assert!(
1925            out.contains("mime_type: \"text/plain\""),
1926            "missing string field must fall back to default; got:\n{out}"
1927        );
1928    }
1929}