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