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