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        // Generate test files per category.
70        for group in groups {
71            let active: Vec<&Fixture> = group
72                .fixtures
73                .iter()
74                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
75                .collect();
76
77            if active.is_empty() {
78                continue;
79            }
80
81            let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
82            let field_resolver = FieldResolver::new(
83                &e2e_config.fields,
84                &e2e_config.fields_optional,
85                &e2e_config.result_fields,
86                &e2e_config.fields_array,
87            );
88            let content = render_test_file(
89                &group.category,
90                &active,
91                e2e_config,
92                &module_path,
93                &function_name,
94                result_var,
95                &e2e_config.call.args,
96                &field_resolver,
97                &e2e_config.fields_enum,
98            );
99            files.push(GeneratedFile {
100                path: output_base.join("test").join(filename),
101                content,
102                generated_header: true,
103            });
104        }
105
106        Ok(files)
107    }
108
109    fn language_name(&self) -> &'static str {
110        "gleam"
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Rendering
116// ---------------------------------------------------------------------------
117
118fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
119    use alef_core::template_versions::hex;
120    let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
121    let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
122    let deps = match dep_mode {
123        crate::config::DependencyMode::Registry => {
124            format!(
125                r#"{pkg_name} = ">= 0.1.0"
126gleam_stdlib = "{stdlib}"
127gleeunit = "{gleeunit}""#
128            )
129        }
130        crate::config::DependencyMode::Local => {
131            format!(
132                r#"{pkg_name} = {{ path = "{pkg_path}" }}
133gleam_stdlib = "{stdlib}"
134gleeunit = "{gleeunit}""#
135            )
136        }
137    };
138
139    format!(
140        r#"name = "e2e_gleam"
141version = "0.1.0"
142target = "erlang"
143
144[dependencies]
145{deps}
146"#
147    )
148}
149
150#[allow(clippy::too_many_arguments)]
151fn render_test_file(
152    _category: &str,
153    fixtures: &[&Fixture],
154    e2e_config: &E2eConfig,
155    module_path: &str,
156    function_name: &str,
157    result_var: &str,
158    args: &[crate::config::ArgMapping],
159    field_resolver: &FieldResolver,
160    enum_fields: &HashSet<String>,
161) -> String {
162    let mut out = String::new();
163    out.push_str(&hash::header(CommentStyle::DoubleSlash));
164    let _ = writeln!(out, "import gleeunit");
165    let _ = writeln!(out, "import gleeunit/should");
166    let _ = writeln!(out, "import {module_path}");
167    let _ = writeln!(out);
168
169    // Track which modules we need to import based on assertions used.
170    let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
171
172    // First pass: determine which helper modules we need.
173    for fixture in fixtures {
174        for assertion in &fixture.assertions {
175            match assertion.assertion_type.as_str() {
176                "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" | "min_length"
177                | "max_length" | "contains_any" => {
178                    needed_modules.insert("string");
179                }
180                "not_empty" | "is_empty" | "count_min" | "count_equals" => {
181                    needed_modules.insert("list");
182                }
183                "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
184                    needed_modules.insert("int");
185                }
186                _ => {}
187            }
188        }
189    }
190
191    // Emit additional imports.
192    for module in &needed_modules {
193        let _ = writeln!(out, "import gleam/{module}");
194    }
195
196    if !needed_modules.is_empty() {
197        let _ = writeln!(out);
198    }
199
200    // Each fixture becomes its own test function.
201    for fixture in fixtures {
202        render_test_case(
203            &mut out,
204            fixture,
205            e2e_config,
206            module_path,
207            function_name,
208            result_var,
209            args,
210            field_resolver,
211            enum_fields,
212        );
213        let _ = writeln!(out);
214    }
215
216    out
217}
218
219#[allow(clippy::too_many_arguments)]
220fn render_test_case(
221    out: &mut String,
222    fixture: &Fixture,
223    e2e_config: &E2eConfig,
224    module_path: &str,
225    _function_name: &str,
226    _result_var: &str,
227    _args: &[crate::config::ArgMapping],
228    field_resolver: &FieldResolver,
229    enum_fields: &HashSet<String>,
230) {
231    // Resolve per-fixture call config.
232    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
233    let lang = "gleam";
234    let call_overrides = call_config.overrides.get(lang);
235    let function_name = call_overrides
236        .and_then(|o| o.function.as_ref())
237        .cloned()
238        .unwrap_or_else(|| call_config.function.clone());
239    let result_var = &call_config.result_var;
240    let args = &call_config.args;
241
242    let test_name = sanitize_ident(&fixture.id);
243    let description = &fixture.description;
244    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
245
246    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
247
248    // gleeunit discovers tests as top-level `pub fn <name>_test()` functions —
249    // emit one function per fixture so failures point at the offending fixture.
250    let _ = writeln!(out, "// {description}");
251    let _ = writeln!(out, "pub fn {test_name}_test() {{");
252
253    for line in &setup_lines {
254        let _ = writeln!(out, "  {line}");
255    }
256
257    if expects_error {
258        let _ = writeln!(out, "  {module_path}.{function_name}({args_str}) |> should.be_error()");
259        let _ = writeln!(out, "}}");
260        return;
261    }
262
263    let _ = writeln!(out, "  let {result_var} = {module_path}.{function_name}({args_str})");
264    let _ = writeln!(out, "  {result_var} |> should.be_ok()");
265
266    for assertion in &fixture.assertions {
267        render_assertion(out, assertion, result_var, field_resolver, enum_fields);
268    }
269
270    let _ = writeln!(out, "}}");
271}
272
273/// Build setup lines and the argument list for the function call.
274fn build_args_and_setup(
275    input: &serde_json::Value,
276    args: &[crate::config::ArgMapping],
277    fixture_id: &str,
278) -> (Vec<String>, String) {
279    if args.is_empty() {
280        return (Vec::new(), String::new());
281    }
282
283    let mut setup_lines: Vec<String> = Vec::new();
284    let mut parts: Vec<String> = Vec::new();
285
286    for arg in args {
287        if arg.arg_type == "mock_url" {
288            setup_lines.push(format!(
289                "let {} = (import \"os\" as os).get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
290                arg.name,
291            ));
292            parts.push(arg.name.clone());
293            continue;
294        }
295
296        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
297        let val = input.get(field);
298        match val {
299            None | Some(serde_json::Value::Null) if arg.optional => {
300                continue;
301            }
302            None | Some(serde_json::Value::Null) => {
303                let default_val = match arg.arg_type.as_str() {
304                    "string" => "\"\"".to_string(),
305                    "int" | "integer" => "0".to_string(),
306                    "float" | "number" => "0.0".to_string(),
307                    "bool" | "boolean" => "False".to_string(),
308                    _ => "Nil".to_string(),
309                };
310                parts.push(default_val);
311            }
312            Some(v) => {
313                parts.push(json_to_gleam(v));
314            }
315        }
316    }
317
318    (setup_lines, parts.join(", "))
319}
320
321fn render_assertion(
322    out: &mut String,
323    assertion: &Assertion,
324    result_var: &str,
325    field_resolver: &FieldResolver,
326    enum_fields: &HashSet<String>,
327) {
328    // Skip assertions on fields that don't exist on the result type.
329    if let Some(f) = &assertion.field {
330        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
331            let _ = writeln!(out, "  // skipped: field '{{f}}' not available on result type");
332            return;
333        }
334    }
335
336    // Determine if this field is an enum type.
337    let _field_is_enum = assertion
338        .field
339        .as_deref()
340        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
341
342    let field_expr = match &assertion.field {
343        Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
344        _ => result_var.to_string(),
345    };
346
347    match assertion.assertion_type.as_str() {
348        "equals" => {
349            if let Some(expected) = &assertion.value {
350                let gleam_val = json_to_gleam(expected);
351                let _ = writeln!(out, "  {field_expr} |> should.equal({gleam_val})");
352            }
353        }
354        "contains" => {
355            if let Some(expected) = &assertion.value {
356                let gleam_val = json_to_gleam(expected);
357                let _ = writeln!(
358                    out,
359                    "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
360                );
361            }
362        }
363        "contains_all" => {
364            if let Some(values) = &assertion.values {
365                for val in values {
366                    let gleam_val = json_to_gleam(val);
367                    let _ = writeln!(
368                        out,
369                        "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
370                    );
371                }
372            }
373        }
374        "not_contains" => {
375            if let Some(expected) = &assertion.value {
376                let gleam_val = json_to_gleam(expected);
377                let _ = writeln!(
378                    out,
379                    "  {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
380                );
381            }
382        }
383        "not_empty" => {
384            let _ = writeln!(out, "  {field_expr} |> list.is_empty |> should.equal(False)");
385        }
386        "is_empty" => {
387            let _ = writeln!(out, "  {field_expr} |> list.is_empty |> should.equal(True)");
388        }
389        "starts_with" => {
390            if let Some(expected) = &assertion.value {
391                let gleam_val = json_to_gleam(expected);
392                let _ = writeln!(
393                    out,
394                    "  {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
395                );
396            }
397        }
398        "ends_with" => {
399            if let Some(expected) = &assertion.value {
400                let gleam_val = json_to_gleam(expected);
401                let _ = writeln!(
402                    out,
403                    "  {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
404                );
405            }
406        }
407        "min_length" => {
408            if let Some(val) = &assertion.value {
409                if let Some(n) = val.as_u64() {
410                    let _ = writeln!(
411                        out,
412                        "  {field_expr} |> string.length |> int.is_at_least({n}) |> should.equal(True)"
413                    );
414                }
415            }
416        }
417        "max_length" => {
418            if let Some(val) = &assertion.value {
419                if let Some(n) = val.as_u64() {
420                    let _ = writeln!(
421                        out,
422                        "  {field_expr} |> string.length |> int.is_at_most({n}) |> should.equal(True)"
423                    );
424                }
425            }
426        }
427        "count_min" => {
428            if let Some(val) = &assertion.value {
429                if let Some(n) = val.as_u64() {
430                    let _ = writeln!(
431                        out,
432                        "  {field_expr} |> list.length |> int.is_at_least({n}) |> should.equal(True)"
433                    );
434                }
435            }
436        }
437        "count_equals" => {
438            if let Some(val) = &assertion.value {
439                if let Some(n) = val.as_u64() {
440                    let _ = writeln!(out, "  {field_expr} |> list.length |> should.equal({n})");
441                }
442            }
443        }
444        "is_true" => {
445            let _ = writeln!(out, "  {field_expr} |> should.equal(True)");
446        }
447        "is_false" => {
448            let _ = writeln!(out, "  {field_expr} |> should.equal(False)");
449        }
450        "not_error" => {
451            // Already handled by the call succeeding.
452        }
453        "error" => {
454            // Handled at the test case level.
455        }
456        "greater_than" => {
457            if let Some(val) = &assertion.value {
458                let gleam_val = json_to_gleam(val);
459                let _ = writeln!(
460                    out,
461                    "  {field_expr} |> int.is_strictly_greater_than({gleam_val}) |> should.equal(True)"
462                );
463            }
464        }
465        "less_than" => {
466            if let Some(val) = &assertion.value {
467                let gleam_val = json_to_gleam(val);
468                let _ = writeln!(
469                    out,
470                    "  {field_expr} |> int.is_strictly_less_than({gleam_val}) |> should.equal(True)"
471                );
472            }
473        }
474        "greater_than_or_equal" => {
475            if let Some(val) = &assertion.value {
476                let gleam_val = json_to_gleam(val);
477                let _ = writeln!(
478                    out,
479                    "  {field_expr} |> int.is_at_least({gleam_val}) |> should.equal(True)"
480                );
481            }
482        }
483        "less_than_or_equal" => {
484            if let Some(val) = &assertion.value {
485                let gleam_val = json_to_gleam(val);
486                let _ = writeln!(
487                    out,
488                    "  {field_expr} |> int.is_at_most({gleam_val}) |> should.equal(True)"
489                );
490            }
491        }
492        "contains_any" => {
493            if let Some(values) = &assertion.values {
494                for val in values {
495                    let gleam_val = json_to_gleam(val);
496                    let _ = writeln!(
497                        out,
498                        "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
499                    );
500                }
501            }
502        }
503        "matches_regex" => {
504            let _ = writeln!(out, "  // regex match not yet implemented for Gleam");
505        }
506        "method_result" => {
507            let _ = writeln!(out, "  // method_result assertions not yet implemented for Gleam");
508        }
509        other => {
510            panic!("Gleam e2e generator: unsupported assertion type: {other}");
511        }
512    }
513}
514
515/// Convert a `serde_json::Value` to a Gleam literal string.
516fn json_to_gleam(value: &serde_json::Value) -> String {
517    match value {
518        serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
519        serde_json::Value::Bool(b) => {
520            if *b {
521                "True".to_string()
522            } else {
523                "False".to_string()
524            }
525        }
526        serde_json::Value::Number(n) => n.to_string(),
527        serde_json::Value::Null => "Nil".to_string(),
528        serde_json::Value::Array(arr) => {
529            let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
530            format!("[{}]", items.join(", "))
531        }
532        serde_json::Value::Object(_) => {
533            let json_str = serde_json::to_string(value).unwrap_or_default();
534            format!("\"{}\"", escape_gleam(&json_str))
535        }
536    }
537}