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