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