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