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//! driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_gleam, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use anyhow::Result;
14use heck::ToSnakeCase;
15use std::collections::HashSet;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19use super::E2eCodegen;
20
21/// Gleam e2e code generator.
22pub struct GleamE2eCodegen;
23
24impl E2eCodegen for GleamE2eCodegen {
25    fn generate(
26        &self,
27        groups: &[FixtureGroup],
28        e2e_config: &E2eConfig,
29        alef_config: &AlefConfig,
30    ) -> Result<Vec<GeneratedFile>> {
31        let lang = self.language_name();
32        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
33
34        let mut files = Vec::new();
35
36        // Resolve call config with overrides.
37        let call = &e2e_config.call;
38        let overrides = call.overrides.get(lang);
39        let module_path = overrides
40            .and_then(|o| o.module.as_ref())
41            .cloned()
42            .unwrap_or_else(|| call.module.clone());
43        let function_name = overrides
44            .and_then(|o| o.function.as_ref())
45            .cloned()
46            .unwrap_or_else(|| call.function.clone());
47        let result_var = &call.result_var;
48
49        // Resolve package config.
50        let gleam_pkg = e2e_config.resolve_package("gleam");
51        let pkg_path = gleam_pkg
52            .as_ref()
53            .and_then(|p| p.path.as_ref())
54            .cloned()
55            .unwrap_or_else(|| "../../packages/gleam".to_string());
56        let pkg_name = gleam_pkg
57            .as_ref()
58            .and_then(|p| p.name.as_ref())
59            .cloned()
60            .unwrap_or_else(|| alef_config.crate_config.name.to_snake_case());
61
62        // Generate gleam.toml.
63        files.push(GeneratedFile {
64            path: output_base.join("gleam.toml"),
65            content: render_gleam_toml(&pkg_path, &pkg_name, e2e_config.dep_mode),
66            generated_header: false,
67        });
68
69        // Gleam requires a `src/` directory even for test-only projects.
70        // Always emit a minimal placeholder module so the project compiles.
71        files.push(GeneratedFile {
72            path: output_base.join("src").join("e2e_gleam.gleam"),
73            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(),
74            generated_header: false,
75        });
76
77        // Track whether any test file was emitted.
78        let mut any_tests = false;
79
80        // Generate test files per category.
81        for group in groups {
82            let active: Vec<&Fixture> = group
83                .fixtures
84                .iter()
85                // Skip http-type fixtures — Gleam tests only cover call-based fixtures
86                // (those with function arguments). HTTP server tests require an HTTP
87                // client library integration that isn't part of the Gleam package.
88                .filter(|f| !f.is_http_test())
89                // Skip fixtures that don't have a Gleam-specific call override pointing
90                // to an exported function. The default `handle_request` function does not
91                // exist in the Gleam binding (which only exports schema construction
92                // functions). Only fixtures with an explicit `call` config that resolves
93                // to a gleam override should generate tests.
94                .filter(|f| {
95                    let call_cfg = e2e_config.resolve_call(f.call.as_deref());
96                    call_cfg.overrides.contains_key(lang)
97                })
98                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
99                .collect();
100
101            if active.is_empty() {
102                continue;
103            }
104
105            let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
106            let field_resolver = FieldResolver::new(
107                &e2e_config.fields,
108                &e2e_config.fields_optional,
109                &e2e_config.result_fields,
110                &e2e_config.fields_array,
111            );
112            let content = render_test_file(
113                &group.category,
114                &active,
115                e2e_config,
116                &module_path,
117                &function_name,
118                result_var,
119                &e2e_config.call.args,
120                &field_resolver,
121                &e2e_config.fields_enum,
122            );
123            files.push(GeneratedFile {
124                path: output_base.join("test").join(filename),
125                content,
126                generated_header: true,
127            });
128            any_tests = true;
129        }
130
131        // When no fixture-driven tests were generated (e.g., the binding only exposes
132        // schema construction functions, not handler functions), emit a minimal smoke
133        // test so `gleam test` still succeeds and compilation is verified.
134        if !any_tests {
135            let smoke = concat!(
136                "// Generated by alef. Do not edit by hand.\n",
137                "// No fixture-driven tests for Gleam — binding only exports schema construction functions\n",
138                "// that require the Elixir NIF to be loaded at runtime. This smoke test verifies\n",
139                "// that the package compiles and is importable without executing NIF calls.\n",
140                "import gleeunit\n",
141                "import gleeunit/should\n",
142                "\n",
143                "pub fn main() {\n",
144                "  gleeunit.main()\n",
145                "}\n",
146                "\n",
147                "pub fn compilation_smoke_test() {\n",
148                "  // The Gleam binding compiles correctly and is a valid Erlang module.\n",
149                "  // NIF-backed functions are not called here because they require the\n",
150                "  // Elixir/Erlang NIF to be loaded at runtime.\n",
151                "  True |> should.equal(True)\n",
152                "}\n",
153            ).to_string();
154            files.push(GeneratedFile {
155                path: output_base.join("test").join("e2e_gleam_test.gleam"),
156                content: smoke,
157                generated_header: false,
158            });
159        }
160
161        Ok(files)
162    }
163
164    fn language_name(&self) -> &'static str {
165        "gleam"
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Rendering
171// ---------------------------------------------------------------------------
172
173fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
174    use alef_core::template_versions::hex;
175    let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
176    let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
177    let deps = match dep_mode {
178        crate::config::DependencyMode::Registry => {
179            format!(
180                r#"{pkg_name} = ">= 0.1.0"
181gleam_stdlib = "{stdlib}"
182gleeunit = "{gleeunit}""#
183            )
184        }
185        crate::config::DependencyMode::Local => {
186            format!(
187                r#"{pkg_name} = {{ path = "{pkg_path}" }}
188gleam_stdlib = "{stdlib}"
189gleeunit = "{gleeunit}""#
190            )
191        }
192    };
193
194    format!(
195        r#"name = "e2e_gleam"
196version = "0.1.0"
197target = "erlang"
198
199[dependencies]
200{deps}
201"#
202    )
203}
204
205#[allow(clippy::too_many_arguments)]
206fn render_test_file(
207    _category: &str,
208    fixtures: &[&Fixture],
209    e2e_config: &E2eConfig,
210    module_path: &str,
211    function_name: &str,
212    result_var: &str,
213    args: &[crate::config::ArgMapping],
214    field_resolver: &FieldResolver,
215    enum_fields: &HashSet<String>,
216) -> String {
217    let mut out = String::new();
218    out.push_str(&hash::header(CommentStyle::DoubleSlash));
219    let _ = writeln!(out, "import gleeunit");
220    let _ = writeln!(out, "import gleeunit/should");
221    let _ = writeln!(out, "import {module_path}");
222    let _ = writeln!(out);
223
224    // Track which modules we need to import based on assertions used.
225    let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
226
227    // First pass: determine which helper modules we need.
228    for fixture in fixtures {
229        for assertion in &fixture.assertions {
230            match assertion.assertion_type.as_str() {
231                "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" | "min_length"
232                | "max_length" | "contains_any" => {
233                    needed_modules.insert("string");
234                }
235                "not_empty" | "is_empty" | "count_min" | "count_equals" => {
236                    needed_modules.insert("list");
237                }
238                "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
239                    needed_modules.insert("int");
240                }
241                _ => {}
242            }
243        }
244    }
245
246    // Emit additional imports.
247    for module in &needed_modules {
248        let _ = writeln!(out, "import gleam/{module}");
249    }
250
251    if !needed_modules.is_empty() {
252        let _ = writeln!(out);
253    }
254
255    // Each fixture becomes its own test function.
256    for fixture in fixtures {
257        render_test_case(
258            &mut out,
259            fixture,
260            e2e_config,
261            module_path,
262            function_name,
263            result_var,
264            args,
265            field_resolver,
266            enum_fields,
267        );
268        let _ = writeln!(out);
269    }
270
271    out
272}
273
274#[allow(clippy::too_many_arguments)]
275fn render_test_case(
276    out: &mut String,
277    fixture: &Fixture,
278    e2e_config: &E2eConfig,
279    module_path: &str,
280    _function_name: &str,
281    _result_var: &str,
282    _args: &[crate::config::ArgMapping],
283    field_resolver: &FieldResolver,
284    enum_fields: &HashSet<String>,
285) {
286    // Resolve per-fixture call config.
287    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
288    let lang = "gleam";
289    let call_overrides = call_config.overrides.get(lang);
290    let function_name = call_overrides
291        .and_then(|o| o.function.as_ref())
292        .cloned()
293        .unwrap_or_else(|| call_config.function.clone());
294    let result_var = &call_config.result_var;
295    let args = &call_config.args;
296
297    // Gleam identifiers must start with a lowercase letter, not `_` or a digit.
298    // Strip any leading underscores or digits that result from numeric-prefixed fixture IDs
299    // (e.g. fixture id "19_413_payload_too_large" → "413_payload_too_large" →
300    // strip leading digits → "payload_too_large").
301    let raw_name = sanitize_ident(&fixture.id);
302    let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
303    let test_name = if stripped.is_empty() { raw_name.as_str() } else { stripped };
304    let description = &fixture.description;
305    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
306
307    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
308
309    // gleeunit discovers tests as top-level `pub fn <name>_test()` functions —
310    // emit one function per fixture so failures point at the offending fixture.
311    let _ = writeln!(out, "// {description}");
312    let _ = writeln!(out, "pub fn {test_name}_test() {{");
313
314    for line in &setup_lines {
315        let _ = writeln!(out, "  {line}");
316    }
317
318    if expects_error {
319        let _ = writeln!(out, "  {module_path}.{function_name}({args_str}) |> should.be_error()");
320        let _ = writeln!(out, "}}");
321        return;
322    }
323
324    let _ = writeln!(out, "  let {result_var} = {module_path}.{function_name}({args_str})");
325    let _ = writeln!(out, "  {result_var} |> should.be_ok()");
326
327    for assertion in &fixture.assertions {
328        render_assertion(out, assertion, result_var, field_resolver, enum_fields);
329    }
330
331    let _ = writeln!(out, "}}");
332}
333
334/// Build setup lines and the argument list for the function call.
335fn build_args_and_setup(
336    input: &serde_json::Value,
337    args: &[crate::config::ArgMapping],
338    fixture_id: &str,
339) -> (Vec<String>, String) {
340    if args.is_empty() {
341        return (Vec::new(), String::new());
342    }
343
344    let mut setup_lines: Vec<String> = Vec::new();
345    let mut parts: Vec<String> = Vec::new();
346
347    for arg in args {
348        if arg.arg_type == "mock_url" {
349            setup_lines.push(format!(
350                "let {} = (import \"os\" as os).get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
351                arg.name,
352            ));
353            parts.push(arg.name.clone());
354            continue;
355        }
356
357        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
358        let val = input.get(field);
359        match val {
360            None | Some(serde_json::Value::Null) if arg.optional => {
361                continue;
362            }
363            None | Some(serde_json::Value::Null) => {
364                let default_val = match arg.arg_type.as_str() {
365                    "string" => "\"\"".to_string(),
366                    "int" | "integer" => "0".to_string(),
367                    "float" | "number" => "0.0".to_string(),
368                    "bool" | "boolean" => "False".to_string(),
369                    _ => "Nil".to_string(),
370                };
371                parts.push(default_val);
372            }
373            Some(v) => {
374                parts.push(json_to_gleam(v));
375            }
376        }
377    }
378
379    (setup_lines, parts.join(", "))
380}
381
382fn render_assertion(
383    out: &mut String,
384    assertion: &Assertion,
385    result_var: &str,
386    field_resolver: &FieldResolver,
387    enum_fields: &HashSet<String>,
388) {
389    // Skip assertions on fields that don't exist on the result type.
390    if let Some(f) = &assertion.field {
391        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
392            let _ = writeln!(out, "  // skipped: field '{{f}}' not available on result type");
393            return;
394        }
395    }
396
397    // Determine if this field is an enum type.
398    let _field_is_enum = assertion
399        .field
400        .as_deref()
401        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
402
403    let field_expr = match &assertion.field {
404        Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
405        _ => result_var.to_string(),
406    };
407
408    match assertion.assertion_type.as_str() {
409        "equals" => {
410            if let Some(expected) = &assertion.value {
411                let gleam_val = json_to_gleam(expected);
412                let _ = writeln!(out, "  {field_expr} |> should.equal({gleam_val})");
413            }
414        }
415        "contains" => {
416            if let Some(expected) = &assertion.value {
417                let gleam_val = json_to_gleam(expected);
418                let _ = writeln!(
419                    out,
420                    "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
421                );
422            }
423        }
424        "contains_all" => {
425            if let Some(values) = &assertion.values {
426                for val in values {
427                    let gleam_val = json_to_gleam(val);
428                    let _ = writeln!(
429                        out,
430                        "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
431                    );
432                }
433            }
434        }
435        "not_contains" => {
436            if let Some(expected) = &assertion.value {
437                let gleam_val = json_to_gleam(expected);
438                let _ = writeln!(
439                    out,
440                    "  {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
441                );
442            }
443        }
444        "not_empty" => {
445            let _ = writeln!(out, "  {field_expr} |> list.is_empty |> should.equal(False)");
446        }
447        "is_empty" => {
448            let _ = writeln!(out, "  {field_expr} |> list.is_empty |> should.equal(True)");
449        }
450        "starts_with" => {
451            if let Some(expected) = &assertion.value {
452                let gleam_val = json_to_gleam(expected);
453                let _ = writeln!(
454                    out,
455                    "  {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
456                );
457            }
458        }
459        "ends_with" => {
460            if let Some(expected) = &assertion.value {
461                let gleam_val = json_to_gleam(expected);
462                let _ = writeln!(
463                    out,
464                    "  {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
465                );
466            }
467        }
468        "min_length" => {
469            if let Some(val) = &assertion.value {
470                if let Some(n) = val.as_u64() {
471                    let _ = writeln!(
472                        out,
473                        "  {field_expr} |> string.length |> int.is_at_least({n}) |> should.equal(True)"
474                    );
475                }
476            }
477        }
478        "max_length" => {
479            if let Some(val) = &assertion.value {
480                if let Some(n) = val.as_u64() {
481                    let _ = writeln!(
482                        out,
483                        "  {field_expr} |> string.length |> int.is_at_most({n}) |> should.equal(True)"
484                    );
485                }
486            }
487        }
488        "count_min" => {
489            if let Some(val) = &assertion.value {
490                if let Some(n) = val.as_u64() {
491                    let _ = writeln!(
492                        out,
493                        "  {field_expr} |> list.length |> int.is_at_least({n}) |> should.equal(True)"
494                    );
495                }
496            }
497        }
498        "count_equals" => {
499            if let Some(val) = &assertion.value {
500                if let Some(n) = val.as_u64() {
501                    let _ = writeln!(out, "  {field_expr} |> list.length |> should.equal({n})");
502                }
503            }
504        }
505        "is_true" => {
506            let _ = writeln!(out, "  {field_expr} |> should.equal(True)");
507        }
508        "is_false" => {
509            let _ = writeln!(out, "  {field_expr} |> should.equal(False)");
510        }
511        "not_error" => {
512            // Already handled by the call succeeding.
513        }
514        "error" => {
515            // Handled at the test case level.
516        }
517        "greater_than" => {
518            if let Some(val) = &assertion.value {
519                let gleam_val = json_to_gleam(val);
520                let _ = writeln!(
521                    out,
522                    "  {field_expr} |> int.is_strictly_greater_than({gleam_val}) |> should.equal(True)"
523                );
524            }
525        }
526        "less_than" => {
527            if let Some(val) = &assertion.value {
528                let gleam_val = json_to_gleam(val);
529                let _ = writeln!(
530                    out,
531                    "  {field_expr} |> int.is_strictly_less_than({gleam_val}) |> should.equal(True)"
532                );
533            }
534        }
535        "greater_than_or_equal" => {
536            if let Some(val) = &assertion.value {
537                let gleam_val = json_to_gleam(val);
538                let _ = writeln!(
539                    out,
540                    "  {field_expr} |> int.is_at_least({gleam_val}) |> should.equal(True)"
541                );
542            }
543        }
544        "less_than_or_equal" => {
545            if let Some(val) = &assertion.value {
546                let gleam_val = json_to_gleam(val);
547                let _ = writeln!(
548                    out,
549                    "  {field_expr} |> int.is_at_most({gleam_val}) |> should.equal(True)"
550                );
551            }
552        }
553        "contains_any" => {
554            if let Some(values) = &assertion.values {
555                for val in values {
556                    let gleam_val = json_to_gleam(val);
557                    let _ = writeln!(
558                        out,
559                        "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
560                    );
561                }
562            }
563        }
564        "matches_regex" => {
565            let _ = writeln!(out, "  // regex match not yet implemented for Gleam");
566        }
567        "method_result" => {
568            let _ = writeln!(out, "  // method_result assertions not yet implemented for Gleam");
569        }
570        other => {
571            panic!("Gleam e2e generator: unsupported assertion type: {other}");
572        }
573    }
574}
575
576/// Convert a `serde_json::Value` to a Gleam literal string.
577fn json_to_gleam(value: &serde_json::Value) -> String {
578    match value {
579        serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
580        serde_json::Value::Bool(b) => {
581            if *b {
582                "True".to_string()
583            } else {
584                "False".to_string()
585            }
586        }
587        serde_json::Value::Number(n) => n.to_string(),
588        serde_json::Value::Null => "Nil".to_string(),
589        serde_json::Value::Array(arr) => {
590            let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
591            format!("[{}]", items.join(", "))
592        }
593        serde_json::Value::Object(_) => {
594            let json_str = serde_json::to_string(value).unwrap_or_default();
595            format!("\"{}\"", escape_gleam(&json_str))
596        }
597    }
598}