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