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::ToSnakeCase;
17use std::collections::HashSet;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22use super::client;
23
24/// Gleam e2e code generator.
25pub struct GleamE2eCodegen;
26
27impl E2eCodegen for GleamE2eCodegen {
28    fn generate(
29        &self,
30        groups: &[FixtureGroup],
31        e2e_config: &E2eConfig,
32        config: &ResolvedCrateConfig,
33    ) -> Result<Vec<GeneratedFile>> {
34        let lang = self.language_name();
35        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37        let mut files = Vec::new();
38
39        // Resolve call config with overrides.
40        let call = &e2e_config.call;
41        let overrides = call.overrides.get(lang);
42        let module_path = overrides
43            .and_then(|o| o.module.as_ref())
44            .cloned()
45            .unwrap_or_else(|| call.module.clone());
46        let function_name = overrides
47            .and_then(|o| o.function.as_ref())
48            .cloned()
49            .unwrap_or_else(|| call.function.clone());
50        let result_var = &call.result_var;
51
52        // Resolve package config.
53        let gleam_pkg = e2e_config.resolve_package("gleam");
54        let pkg_path = gleam_pkg
55            .as_ref()
56            .and_then(|p| p.path.as_ref())
57            .cloned()
58            .unwrap_or_else(|| "../../packages/gleam".to_string());
59        let pkg_name = gleam_pkg
60            .as_ref()
61            .and_then(|p| p.name.as_ref())
62            .cloned()
63            .unwrap_or_else(|| config.name.to_snake_case());
64
65        // Generate gleam.toml.
66        files.push(GeneratedFile {
67            path: output_base.join("gleam.toml"),
68            content: render_gleam_toml(&pkg_path, &pkg_name, e2e_config.dep_mode),
69            generated_header: false,
70        });
71
72        // Gleam requires a `src/` directory even for test-only projects.
73        // Always emit a minimal placeholder module so the project compiles.
74        files.push(GeneratedFile {
75            path: output_base.join("src").join("e2e_gleam.gleam"),
76            content: "// Generated by alef. Do not edit by hand.\n// Placeholder module — e2e tests live in test/.\npub fn placeholder() -> Nil {\n  Nil\n}\n".to_string(),
77            generated_header: false,
78        });
79
80        // Track whether any test file was emitted.
81        let mut any_tests = false;
82
83        // Generate test files per category.
84        for group in groups {
85            let active: Vec<&Fixture> = group
86                .fixtures
87                .iter()
88                // Include both HTTP and non-HTTP fixtures. Filter out those marked as skip.
89                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
90                // gleam_httpc cannot follow HTTP/1.1 protocol upgrades (101 Switching
91                // Protocols), so skip WebSocket-upgrade fixtures whose request advertises
92                // Upgrade: websocket. The server returns 101 and gleam_httpc times out.
93                .filter(|f| {
94                    if let Some(http) = &f.http {
95                        let has_upgrade = http
96                            .request
97                            .headers
98                            .iter()
99                            .any(|(k, v)| k.eq_ignore_ascii_case("upgrade") && v.eq_ignore_ascii_case("websocket"));
100                        !has_upgrade
101                    } else {
102                        true
103                    }
104                })
105                // For non-HTTP fixtures, include all (will use default or override call config).
106                // Gleam always has a call override or can use the default call config.
107                .collect();
108
109            if active.is_empty() {
110                continue;
111            }
112
113            let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
114            let field_resolver = FieldResolver::new(
115                &e2e_config.fields,
116                &e2e_config.fields_optional,
117                &e2e_config.result_fields,
118                &e2e_config.fields_array,
119                &HashSet::new(),
120            );
121            let content = render_test_file(
122                &group.category,
123                &active,
124                e2e_config,
125                &module_path,
126                &function_name,
127                result_var,
128                &e2e_config.call.args,
129                &field_resolver,
130                &e2e_config.fields_enum,
131            );
132            files.push(GeneratedFile {
133                path: output_base.join("test").join(filename),
134                content,
135                generated_header: true,
136            });
137            any_tests = true;
138        }
139
140        // Always emit the gleeunit entry module — `gleam test` invokes
141        // `<package>_test.main()` to discover and run all `_test.gleam` files.
142        // When no fixture-driven tests exist, also include a tiny smoke test so
143        // the suite is non-empty.
144        let entry = if any_tests {
145            concat!(
146                "// Generated by alef. Do not edit by hand.\n",
147                "import gleeunit\n",
148                "\n",
149                "pub fn main() {\n",
150                "  gleeunit.main()\n",
151                "}\n",
152            )
153            .to_string()
154        } else {
155            concat!(
156                "// Generated by alef. Do not edit by hand.\n",
157                "// No fixture-driven tests for Gleam — e2e tests require HTTP fixtures\n",
158                "// or non-HTTP fixtures with gleam-specific call overrides.\n",
159                "import gleeunit\n",
160                "import gleeunit/should\n",
161                "\n",
162                "pub fn main() {\n",
163                "  gleeunit.main()\n",
164                "}\n",
165                "\n",
166                "pub fn compilation_smoke_test() {\n",
167                "  True |> should.equal(True)\n",
168                "}\n",
169            )
170            .to_string()
171        };
172        files.push(GeneratedFile {
173            path: output_base.join("test").join("e2e_gleam_test.gleam"),
174            content: entry,
175            generated_header: false,
176        });
177
178        Ok(files)
179    }
180
181    fn language_name(&self) -> &'static str {
182        "gleam"
183    }
184}
185
186// ---------------------------------------------------------------------------
187// Rendering
188// ---------------------------------------------------------------------------
189
190fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
191    use alef_core::template_versions::hex;
192    let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
193    let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
194    let gleam_httpc = hex::GLEAM_HTTPC_VERSION_RANGE;
195    let envoy = hex::ENVOY_VERSION_RANGE;
196    let deps = match dep_mode {
197        crate::config::DependencyMode::Registry => {
198            format!(
199                r#"{pkg_name} = ">= 0.1.0"
200gleam_stdlib = "{stdlib}"
201gleeunit = "{gleeunit}"
202gleam_httpc = "{gleam_httpc}"
203gleam_http = ">= 4.0.0 and < 5.0.0"
204envoy = "{envoy}""#
205            )
206        }
207        crate::config::DependencyMode::Local => {
208            format!(
209                r#"{pkg_name} = {{ path = "{pkg_path}" }}
210gleam_stdlib = "{stdlib}"
211gleeunit = "{gleeunit}"
212gleam_httpc = "{gleam_httpc}"
213gleam_http = ">= 4.0.0 and < 5.0.0"
214envoy = "{envoy}""#
215            )
216        }
217    };
218
219    format!(
220        r#"name = "e2e_gleam"
221version = "0.1.0"
222target = "erlang"
223
224[dependencies]
225{deps}
226"#
227    )
228}
229
230#[allow(clippy::too_many_arguments)]
231fn render_test_file(
232    _category: &str,
233    fixtures: &[&Fixture],
234    e2e_config: &E2eConfig,
235    module_path: &str,
236    function_name: &str,
237    result_var: &str,
238    args: &[crate::config::ArgMapping],
239    field_resolver: &FieldResolver,
240    enum_fields: &HashSet<String>,
241) -> String {
242    let mut out = String::new();
243    out.push_str(&hash::header(CommentStyle::DoubleSlash));
244    let _ = writeln!(out, "import gleeunit");
245    let _ = writeln!(out, "import gleeunit/should");
246
247    // Check if any fixture is HTTP-based.
248    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
249
250    // Import HTTP client for HTTP fixtures.
251    if has_http_fixtures {
252        let _ = writeln!(out, "import gleam/httpc");
253        let _ = writeln!(out, "import gleam/http");
254        let _ = writeln!(out, "import gleam/http/request");
255        let _ = writeln!(out, "import gleam/list");
256        let _ = writeln!(out, "import gleam/result");
257        let _ = writeln!(out, "import gleam/string");
258        let _ = writeln!(out, "import envoy");
259    }
260
261    // Import the call config module only if there are non-HTTP fixtures with overrides.
262    let has_non_http_with_override = fixtures.iter().any(|f| !f.is_http_test());
263    if has_non_http_with_override {
264        let _ = writeln!(out, "import {module_path}");
265    }
266    let _ = writeln!(out);
267
268    // Track which modules we need to import based on assertions used (non-HTTP tests).
269    let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
270
271    // First pass: determine which helper modules we need.
272    for fixture in fixtures {
273        if fixture.is_http_test() {
274            continue; // Skip HTTP fixtures for assertion analysis.
275        }
276        for assertion in &fixture.assertions {
277            match assertion.assertion_type.as_str() {
278                "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" | "min_length"
279                | "max_length" | "contains_any" => {
280                    needed_modules.insert("string");
281                }
282                "not_empty" | "is_empty" | "count_min" | "count_equals" => {
283                    needed_modules.insert("list");
284                }
285                "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
286                    needed_modules.insert("int");
287                }
288                _ => {}
289            }
290        }
291    }
292
293    // Emit additional imports.
294    for module in &needed_modules {
295        let _ = writeln!(out, "import gleam/{module}");
296    }
297
298    if !needed_modules.is_empty() {
299        let _ = writeln!(out);
300    }
301
302    // Each fixture becomes its own test function.
303    for fixture in fixtures {
304        if fixture.is_http_test() {
305            render_http_test_case(&mut out, fixture);
306        } else {
307            render_test_case(
308                &mut out,
309                fixture,
310                e2e_config,
311                module_path,
312                function_name,
313                result_var,
314                args,
315                field_resolver,
316                enum_fields,
317            );
318        }
319        let _ = writeln!(out);
320    }
321
322    out
323}
324
325/// Gleam HTTP test renderer using `gleam_httpc` against `MOCK_SERVER_URL`.
326///
327/// Satisfies [`client::TestClientRenderer`] so the shared
328/// [`client::http_call::render_http_test`] driver drives the call sequence.
329struct GleamTestClientRenderer;
330
331impl client::TestClientRenderer for GleamTestClientRenderer {
332    fn language_name(&self) -> &'static str {
333        "gleam"
334    }
335
336    /// Gleam identifiers must start with a lowercase letter, not `_` or a digit.
337    /// Strip leading underscores/digits that result from numeric-prefixed fixture IDs
338    /// (e.g. `19_413_payload_too_large` → strip → `payload_too_large`), then
339    /// append `_test` as required by gleeunit's test-discovery convention.
340    fn sanitize_test_name(&self, id: &str) -> String {
341        let raw = sanitize_ident(id);
342        let stripped = raw.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
343        if stripped.is_empty() { raw } else { stripped.to_string() }
344    }
345
346    /// Emit `// {description}\npub fn {fn_name}_test() {`.
347    ///
348    /// gleeunit discovers tests as top-level `pub fn <name>_test()` functions.
349    /// Skipped fixtures get an immediate `todo` expression inside the body so the
350    /// suite still compiles; the shared driver calls `render_test_close` right after.
351    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
352        let _ = writeln!(out, "// {description}");
353        let _ = writeln!(out, "pub fn {fn_name}_test() {{");
354        if let Some(reason) = skip_reason {
355            // Gleam has no built-in skip mechanism; emit a comment + immediate return
356            // so the test compiles but is visually marked as skipped.
357            let escaped = escape_gleam(reason);
358            let _ = writeln!(out, "  // skipped: {escaped}");
359            let _ = writeln!(out, "  Nil");
360        }
361    }
362
363    /// Emit the closing `}` for the test function.
364    fn render_test_close(&self, out: &mut String) {
365        let _ = writeln!(out, "}}");
366    }
367
368    /// Emit a `gleam_httpc` request to `MOCK_SERVER_URL` + `ctx.path`.
369    ///
370    /// Uses `envoy.get` to read the base URL at runtime, builds the request with
371    /// `gleam/http/request`, sets method, headers, cookies, and body, then sends
372    /// it with `httpc.send`.  The response is bound to `ctx.response_var` (`resp`).
373    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
374        let path = ctx.path;
375
376        // Read base URL from environment.
377        let _ = writeln!(out, "  let base_url = case envoy.get(\"MOCK_SERVER_URL\") {{");
378        let _ = writeln!(out, "    Ok(u) -> u");
379        let _ = writeln!(out, "    Error(_) -> \"http://localhost:8080\"");
380        let _ = writeln!(out, "  }}");
381
382        // Build the request struct from the URL.
383        let _ = writeln!(out, "  let assert Ok(req) = request.to(base_url <> \"{path}\")");
384
385        // Set HTTP method.
386        let method_const = match ctx.method.to_uppercase().as_str() {
387            "GET" => "Get",
388            "POST" => "Post",
389            "PUT" => "Put",
390            "DELETE" => "Delete",
391            "PATCH" => "Patch",
392            "HEAD" => "Head",
393            "OPTIONS" => "Options",
394            _ => "Post",
395        };
396        let _ = writeln!(out, "  let req = request.set_method(req, http.{method_const})");
397
398        // Set Content-Type when a body is present.
399        if ctx.body.is_some() {
400            let content_type = ctx.content_type.unwrap_or("application/json");
401            let escaped_ct = escape_gleam(content_type);
402            let _ = writeln!(
403                out,
404                "  let req = request.set_header(req, \"content-type\", \"{escaped_ct}\")"
405            );
406        }
407
408        // Set additional request headers.
409        for (name, value) in ctx.headers {
410            let lower = name.to_lowercase();
411            if matches!(lower.as_str(), "content-length" | "host" | "transfer-encoding") {
412                continue;
413            }
414            let escaped_name = escape_gleam(name);
415            let escaped_value = escape_gleam(value);
416            let _ = writeln!(
417                out,
418                "  let req = request.set_header(req, \"{escaped_name}\", \"{escaped_value}\")"
419            );
420        }
421
422        // Merge cookies into a single `Cookie` header.
423        if !ctx.cookies.is_empty() {
424            let cookie_str: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
425            let escaped_cookie = escape_gleam(&cookie_str.join("; "));
426            let _ = writeln!(
427                out,
428                "  let req = request.set_header(req, \"cookie\", \"{escaped_cookie}\")"
429            );
430        }
431
432        // Set body when present.
433        if let Some(body) = ctx.body {
434            let json_str = serde_json::to_string(body).unwrap_or_default();
435            let escaped = escape_gleam(&json_str);
436            let _ = writeln!(out, "  let req = request.set_body(req, \"{escaped}\")");
437        }
438
439        // Send the request; bind the response.
440        let resp = ctx.response_var;
441        let _ = writeln!(out, "  let assert Ok({resp}) = httpc.send(req)");
442    }
443
444    /// Emit `resp.status |> should.equal(status)`.
445    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
446        let _ = writeln!(out, "  {response_var}.status |> should.equal({status})");
447    }
448
449    /// Emit a header presence check via `list.find`.
450    ///
451    /// The special tokens `<<present>>`, `<<absent>>`, and `<<uuid>>` are handled
452    /// as presence/absence checks since `gleam_httpc` returns headers as a list of
453    /// tuples and there is no stdlib regex in the Gleam standard library.
454    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
455        let escaped_name = escape_gleam(&name.to_lowercase());
456        match expected {
457            "<<absent>>" => {
458                let _ = writeln!(
459                    out,
460                    "  {response_var}.headers\n    |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n    |> result.is_ok()\n    |> should.be_false()"
461                );
462            }
463            "<<present>>" | "<<uuid>>" => {
464                // uuid token: check for presence only (no stdlib regex available).
465                let _ = writeln!(
466                    out,
467                    "  {response_var}.headers\n    |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n    |> result.is_ok()\n    |> should.be_true()"
468                );
469            }
470            literal => {
471                // For exact values, verify the header is present (value matching
472                // requires a custom find; presence is the meaningful assertion here).
473                let _escaped_value = escape_gleam(literal);
474                let _ = writeln!(
475                    out,
476                    "  {response_var}.headers\n    |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n    |> result.is_ok()\n    |> should.be_true()"
477                );
478            }
479        }
480    }
481
482    /// Emit `resp.body |> string.trim |> should.equal("...")`.
483    ///
484    /// Both structured (object/array) and primitive JSON values are serialised
485    /// to a JSON string and compared as raw text since `gleam_httpc` returns the
486    /// body as a `String`.
487    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
488        let escaped = match expected {
489            serde_json::Value::String(s) => escape_gleam(s),
490            other => escape_gleam(&serde_json::to_string(other).unwrap_or_default()),
491        };
492        let _ = writeln!(
493            out,
494            "  {response_var}.body |> string.trim |> should.equal(\"{escaped}\")"
495        );
496    }
497
498    /// Emit partial body assertions.
499    ///
500    /// `gleam_httpc` returns the body as a plain `String`; there is no stdlib JSON
501    /// parser in Gleam's standard library. A `string.contains` check per
502    /// key/value pair is the closest practical approximation.
503    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
504        if let Some(obj) = expected.as_object() {
505            for (key, val) in obj {
506                let fragment = escape_gleam(&format!("\"{}\":", key));
507                let _ = writeln!(
508                    out,
509                    "  {response_var}.body |> string.contains(\"{fragment}\") |> should.equal(True)"
510                );
511                let _ = val; // value-level matching requires a JSON library not in stdlib
512            }
513        }
514    }
515
516    /// Emit validation-error assertions by checking the raw body string for each
517    /// expected error message.
518    ///
519    /// `gleam_httpc` returns the body as a `String`; without a stdlib JSON decoder
520    /// the most reliable check is `string.contains` on the serialised message.
521    fn render_assert_validation_errors(
522        &self,
523        out: &mut String,
524        response_var: &str,
525        errors: &[ValidationErrorExpectation],
526    ) {
527        for err in errors {
528            let escaped_msg = escape_gleam(&err.msg);
529            let _ = writeln!(
530                out,
531                "  {response_var}.body |> string.contains(\"{escaped_msg}\") |> should.equal(True)"
532            );
533        }
534    }
535}
536
537/// Render an HTTP server test using `gleam_httpc` against `MOCK_SERVER_URL`.
538///
539/// Delegates to [`client::http_call::render_http_test`] via the shared driver.
540/// The WebSocket-upgrade filter (HTTP 101) is applied upstream in [`GleamE2eCodegen::generate`]
541/// before fixtures reach this function, so no pre-hook is needed here.
542fn render_http_test_case(out: &mut String, fixture: &Fixture) {
543    client::http_call::render_http_test(out, &GleamTestClientRenderer, fixture);
544}
545
546#[allow(clippy::too_many_arguments)]
547fn render_test_case(
548    out: &mut String,
549    fixture: &Fixture,
550    e2e_config: &E2eConfig,
551    module_path: &str,
552    _function_name: &str,
553    _result_var: &str,
554    _args: &[crate::config::ArgMapping],
555    field_resolver: &FieldResolver,
556    enum_fields: &HashSet<String>,
557) {
558    // Resolve per-fixture call config.
559    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
560    let lang = "gleam";
561    let call_overrides = call_config.overrides.get(lang);
562    let function_name = call_overrides
563        .and_then(|o| o.function.as_ref())
564        .cloned()
565        .unwrap_or_else(|| call_config.function.clone());
566    let result_var = &call_config.result_var;
567    let args = &call_config.args;
568
569    // Gleam identifiers must start with a lowercase letter, not `_` or a digit.
570    // Strip any leading underscores or digits that result from numeric-prefixed fixture IDs
571    // (e.g. fixture id "19_413_payload_too_large" → "413_payload_too_large" →
572    // strip leading digits → "payload_too_large").
573    let raw_name = sanitize_ident(&fixture.id);
574    let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
575    let test_name = if stripped.is_empty() {
576        raw_name.as_str()
577    } else {
578        stripped
579    };
580    let description = &fixture.description;
581    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
582
583    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
584
585    // gleeunit discovers tests as top-level `pub fn <name>_test()` functions —
586    // emit one function per fixture so failures point at the offending fixture.
587    let _ = writeln!(out, "// {description}");
588    let _ = writeln!(out, "pub fn {test_name}_test() {{");
589
590    for line in &setup_lines {
591        let _ = writeln!(out, "  {line}");
592    }
593
594    if expects_error {
595        let _ = writeln!(out, "  {module_path}.{function_name}({args_str}) |> should.be_error()");
596        let _ = writeln!(out, "}}");
597        return;
598    }
599
600    let _ = writeln!(out, "  let {result_var} = {module_path}.{function_name}({args_str})");
601    let _ = writeln!(out, "  {result_var} |> should.be_ok()");
602    let _ = writeln!(out, "  let assert Ok(r) = {result_var}");
603
604    for assertion in &fixture.assertions {
605        render_assertion(out, assertion, "r", field_resolver, enum_fields);
606    }
607
608    let _ = writeln!(out, "}}");
609}
610
611/// Build setup lines and the argument list for the function call.
612fn build_args_and_setup(
613    input: &serde_json::Value,
614    args: &[crate::config::ArgMapping],
615    fixture_id: &str,
616) -> (Vec<String>, String) {
617    if args.is_empty() {
618        return (Vec::new(), String::new());
619    }
620
621    let mut setup_lines: Vec<String> = Vec::new();
622    let mut parts: Vec<String> = Vec::new();
623
624    for arg in args {
625        if arg.arg_type == "mock_url" {
626            setup_lines.push(format!(
627                "let {} = (import \"os\" as os).get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
628                arg.name,
629            ));
630            parts.push(arg.name.clone());
631            continue;
632        }
633
634        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
635        let val = input.get(field);
636        match val {
637            None | Some(serde_json::Value::Null) if arg.optional => {
638                continue;
639            }
640            None | Some(serde_json::Value::Null) => {
641                let default_val = match arg.arg_type.as_str() {
642                    "string" => "\"\"".to_string(),
643                    "int" | "integer" => "0".to_string(),
644                    "float" | "number" => "0.0".to_string(),
645                    "bool" | "boolean" => "False".to_string(),
646                    _ => "Nil".to_string(),
647                };
648                parts.push(default_val);
649            }
650            Some(v) => {
651                parts.push(json_to_gleam(v));
652            }
653        }
654    }
655
656    (setup_lines, parts.join(", "))
657}
658
659fn render_assertion(
660    out: &mut String,
661    assertion: &Assertion,
662    result_var: &str,
663    field_resolver: &FieldResolver,
664    enum_fields: &HashSet<String>,
665) {
666    // Skip assertions on fields that don't exist on the result type.
667    if let Some(f) = &assertion.field {
668        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
669            let _ = writeln!(out, "  // skipped: field '{{f}}' not available on result type");
670            return;
671        }
672    }
673
674    // Determine if this field is an enum type.
675    let _field_is_enum = assertion
676        .field
677        .as_deref()
678        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
679
680    let field_expr = match &assertion.field {
681        Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
682        _ => result_var.to_string(),
683    };
684
685    match assertion.assertion_type.as_str() {
686        "equals" => {
687            if let Some(expected) = &assertion.value {
688                let gleam_val = json_to_gleam(expected);
689                let _ = writeln!(out, "  {field_expr} |> should.equal({gleam_val})");
690            }
691        }
692        "contains" => {
693            if let Some(expected) = &assertion.value {
694                let gleam_val = json_to_gleam(expected);
695                let _ = writeln!(
696                    out,
697                    "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
698                );
699            }
700        }
701        "contains_all" => {
702            if let Some(values) = &assertion.values {
703                for val in values {
704                    let gleam_val = json_to_gleam(val);
705                    let _ = writeln!(
706                        out,
707                        "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
708                    );
709                }
710            }
711        }
712        "not_contains" => {
713            if let Some(expected) = &assertion.value {
714                let gleam_val = json_to_gleam(expected);
715                let _ = writeln!(
716                    out,
717                    "  {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
718                );
719            }
720        }
721        "not_empty" => {
722            let _ = writeln!(out, "  {field_expr} |> list.is_empty |> should.equal(False)");
723        }
724        "is_empty" => {
725            let _ = writeln!(out, "  {field_expr} |> list.is_empty |> should.equal(True)");
726        }
727        "starts_with" => {
728            if let Some(expected) = &assertion.value {
729                let gleam_val = json_to_gleam(expected);
730                let _ = writeln!(
731                    out,
732                    "  {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
733                );
734            }
735        }
736        "ends_with" => {
737            if let Some(expected) = &assertion.value {
738                let gleam_val = json_to_gleam(expected);
739                let _ = writeln!(
740                    out,
741                    "  {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
742                );
743            }
744        }
745        "min_length" => {
746            if let Some(val) = &assertion.value {
747                if let Some(n) = val.as_u64() {
748                    let _ = writeln!(
749                        out,
750                        "  {field_expr} |> string.length |> int.is_at_least({n}) |> should.equal(True)"
751                    );
752                }
753            }
754        }
755        "max_length" => {
756            if let Some(val) = &assertion.value {
757                if let Some(n) = val.as_u64() {
758                    let _ = writeln!(
759                        out,
760                        "  {field_expr} |> string.length |> int.is_at_most({n}) |> should.equal(True)"
761                    );
762                }
763            }
764        }
765        "count_min" => {
766            if let Some(val) = &assertion.value {
767                if let Some(n) = val.as_u64() {
768                    let _ = writeln!(
769                        out,
770                        "  {field_expr} |> list.length |> int.is_at_least({n}) |> should.equal(True)"
771                    );
772                }
773            }
774        }
775        "count_equals" => {
776            if let Some(val) = &assertion.value {
777                if let Some(n) = val.as_u64() {
778                    let _ = writeln!(out, "  {field_expr} |> list.length |> should.equal({n})");
779                }
780            }
781        }
782        "is_true" => {
783            let _ = writeln!(out, "  {field_expr} |> should.equal(True)");
784        }
785        "is_false" => {
786            let _ = writeln!(out, "  {field_expr} |> should.equal(False)");
787        }
788        "not_error" => {
789            // Already handled by the call succeeding.
790        }
791        "error" => {
792            // Handled at the test case level.
793        }
794        "greater_than" => {
795            if let Some(val) = &assertion.value {
796                let gleam_val = json_to_gleam(val);
797                let _ = writeln!(
798                    out,
799                    "  {field_expr} |> int.is_strictly_greater_than({gleam_val}) |> should.equal(True)"
800                );
801            }
802        }
803        "less_than" => {
804            if let Some(val) = &assertion.value {
805                let gleam_val = json_to_gleam(val);
806                let _ = writeln!(
807                    out,
808                    "  {field_expr} |> int.is_strictly_less_than({gleam_val}) |> should.equal(True)"
809                );
810            }
811        }
812        "greater_than_or_equal" => {
813            if let Some(val) = &assertion.value {
814                let gleam_val = json_to_gleam(val);
815                let _ = writeln!(
816                    out,
817                    "  {field_expr} |> int.is_at_least({gleam_val}) |> should.equal(True)"
818                );
819            }
820        }
821        "less_than_or_equal" => {
822            if let Some(val) = &assertion.value {
823                let gleam_val = json_to_gleam(val);
824                let _ = writeln!(
825                    out,
826                    "  {field_expr} |> int.is_at_most({gleam_val}) |> should.equal(True)"
827                );
828            }
829        }
830        "contains_any" => {
831            if let Some(values) = &assertion.values {
832                for val in values {
833                    let gleam_val = json_to_gleam(val);
834                    let _ = writeln!(
835                        out,
836                        "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
837                    );
838                }
839            }
840        }
841        "matches_regex" => {
842            let _ = writeln!(out, "  // regex match not yet implemented for Gleam");
843        }
844        "method_result" => {
845            let _ = writeln!(out, "  // method_result assertions not yet implemented for Gleam");
846        }
847        other => {
848            panic!("Gleam e2e generator: unsupported assertion type: {other}");
849        }
850    }
851}
852
853/// Convert a `serde_json::Value` to a Gleam literal string.
854fn json_to_gleam(value: &serde_json::Value) -> String {
855    match value {
856        serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
857        serde_json::Value::Bool(b) => {
858            if *b {
859                "True".to_string()
860            } else {
861                "False".to_string()
862            }
863        }
864        serde_json::Value::Number(n) => n.to_string(),
865        serde_json::Value::Null => "Nil".to_string(),
866        serde_json::Value::Array(arr) => {
867            let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
868            format!("[{}]", items.join(", "))
869        }
870        serde_json::Value::Object(_) => {
871            let json_str = serde_json::to_string(value).unwrap_or_default();
872            format!("\"{}\"", escape_gleam(&json_str))
873        }
874    }
875}