Skip to main content

alef_e2e/codegen/
gleam.rs

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