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