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