Skip to main content

alef_e2e/codegen/
gleam.rs

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