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