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    // Gleam identifiers must start with a lowercase letter, not `_`.
243    // Strip any leading underscores that result from numeric-prefixed fixture IDs.
244    let raw_name = sanitize_ident(&fixture.id);
245    let test_name = raw_name.trim_start_matches('_');
246    let test_name = if test_name.is_empty() {
247        raw_name.as_str()
248    } else {
249        test_name
250    };
251    let description = &fixture.description;
252    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
253
254    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
255
256    // gleeunit discovers tests as top-level `pub fn <name>_test()` functions —
257    // emit one function per fixture so failures point at the offending fixture.
258    let _ = writeln!(out, "// {description}");
259    let _ = writeln!(out, "pub fn {test_name}_test() {{");
260
261    for line in &setup_lines {
262        let _ = writeln!(out, "  {line}");
263    }
264
265    if expects_error {
266        let _ = writeln!(out, "  {module_path}.{function_name}({args_str}) |> should.be_error()");
267        let _ = writeln!(out, "}}");
268        return;
269    }
270
271    let _ = writeln!(out, "  let {result_var} = {module_path}.{function_name}({args_str})");
272    let _ = writeln!(out, "  {result_var} |> should.be_ok()");
273
274    for assertion in &fixture.assertions {
275        render_assertion(out, assertion, result_var, field_resolver, enum_fields);
276    }
277
278    let _ = writeln!(out, "}}");
279}
280
281/// Build setup lines and the argument list for the function call.
282fn build_args_and_setup(
283    input: &serde_json::Value,
284    args: &[crate::config::ArgMapping],
285    fixture_id: &str,
286) -> (Vec<String>, String) {
287    if args.is_empty() {
288        return (Vec::new(), String::new());
289    }
290
291    let mut setup_lines: Vec<String> = Vec::new();
292    let mut parts: Vec<String> = Vec::new();
293
294    for arg in args {
295        if arg.arg_type == "mock_url" {
296            setup_lines.push(format!(
297                "let {} = (import \"os\" as os).get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
298                arg.name,
299            ));
300            parts.push(arg.name.clone());
301            continue;
302        }
303
304        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
305        let val = input.get(field);
306        match val {
307            None | Some(serde_json::Value::Null) if arg.optional => {
308                continue;
309            }
310            None | Some(serde_json::Value::Null) => {
311                let default_val = match arg.arg_type.as_str() {
312                    "string" => "\"\"".to_string(),
313                    "int" | "integer" => "0".to_string(),
314                    "float" | "number" => "0.0".to_string(),
315                    "bool" | "boolean" => "False".to_string(),
316                    _ => "Nil".to_string(),
317                };
318                parts.push(default_val);
319            }
320            Some(v) => {
321                parts.push(json_to_gleam(v));
322            }
323        }
324    }
325
326    (setup_lines, parts.join(", "))
327}
328
329fn render_assertion(
330    out: &mut String,
331    assertion: &Assertion,
332    result_var: &str,
333    field_resolver: &FieldResolver,
334    enum_fields: &HashSet<String>,
335) {
336    // Skip assertions on fields that don't exist on the result type.
337    if let Some(f) = &assertion.field {
338        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
339            let _ = writeln!(out, "  // skipped: field '{{f}}' not available on result type");
340            return;
341        }
342    }
343
344    // Determine if this field is an enum type.
345    let _field_is_enum = assertion
346        .field
347        .as_deref()
348        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
349
350    let field_expr = match &assertion.field {
351        Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
352        _ => result_var.to_string(),
353    };
354
355    match assertion.assertion_type.as_str() {
356        "equals" => {
357            if let Some(expected) = &assertion.value {
358                let gleam_val = json_to_gleam(expected);
359                let _ = writeln!(out, "  {field_expr} |> should.equal({gleam_val})");
360            }
361        }
362        "contains" => {
363            if let Some(expected) = &assertion.value {
364                let gleam_val = json_to_gleam(expected);
365                let _ = writeln!(
366                    out,
367                    "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
368                );
369            }
370        }
371        "contains_all" => {
372            if let Some(values) = &assertion.values {
373                for val in values {
374                    let gleam_val = json_to_gleam(val);
375                    let _ = writeln!(
376                        out,
377                        "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
378                    );
379                }
380            }
381        }
382        "not_contains" => {
383            if let Some(expected) = &assertion.value {
384                let gleam_val = json_to_gleam(expected);
385                let _ = writeln!(
386                    out,
387                    "  {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
388                );
389            }
390        }
391        "not_empty" => {
392            let _ = writeln!(out, "  {field_expr} |> list.is_empty |> should.equal(False)");
393        }
394        "is_empty" => {
395            let _ = writeln!(out, "  {field_expr} |> list.is_empty |> should.equal(True)");
396        }
397        "starts_with" => {
398            if let Some(expected) = &assertion.value {
399                let gleam_val = json_to_gleam(expected);
400                let _ = writeln!(
401                    out,
402                    "  {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
403                );
404            }
405        }
406        "ends_with" => {
407            if let Some(expected) = &assertion.value {
408                let gleam_val = json_to_gleam(expected);
409                let _ = writeln!(
410                    out,
411                    "  {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
412                );
413            }
414        }
415        "min_length" => {
416            if let Some(val) = &assertion.value {
417                if let Some(n) = val.as_u64() {
418                    let _ = writeln!(
419                        out,
420                        "  {field_expr} |> string.length |> int.is_at_least({n}) |> should.equal(True)"
421                    );
422                }
423            }
424        }
425        "max_length" => {
426            if let Some(val) = &assertion.value {
427                if let Some(n) = val.as_u64() {
428                    let _ = writeln!(
429                        out,
430                        "  {field_expr} |> string.length |> int.is_at_most({n}) |> should.equal(True)"
431                    );
432                }
433            }
434        }
435        "count_min" => {
436            if let Some(val) = &assertion.value {
437                if let Some(n) = val.as_u64() {
438                    let _ = writeln!(
439                        out,
440                        "  {field_expr} |> list.length |> int.is_at_least({n}) |> should.equal(True)"
441                    );
442                }
443            }
444        }
445        "count_equals" => {
446            if let Some(val) = &assertion.value {
447                if let Some(n) = val.as_u64() {
448                    let _ = writeln!(out, "  {field_expr} |> list.length |> should.equal({n})");
449                }
450            }
451        }
452        "is_true" => {
453            let _ = writeln!(out, "  {field_expr} |> should.equal(True)");
454        }
455        "is_false" => {
456            let _ = writeln!(out, "  {field_expr} |> should.equal(False)");
457        }
458        "not_error" => {
459            // Already handled by the call succeeding.
460        }
461        "error" => {
462            // Handled at the test case level.
463        }
464        "greater_than" => {
465            if let Some(val) = &assertion.value {
466                let gleam_val = json_to_gleam(val);
467                let _ = writeln!(
468                    out,
469                    "  {field_expr} |> int.is_strictly_greater_than({gleam_val}) |> should.equal(True)"
470                );
471            }
472        }
473        "less_than" => {
474            if let Some(val) = &assertion.value {
475                let gleam_val = json_to_gleam(val);
476                let _ = writeln!(
477                    out,
478                    "  {field_expr} |> int.is_strictly_less_than({gleam_val}) |> should.equal(True)"
479                );
480            }
481        }
482        "greater_than_or_equal" => {
483            if let Some(val) = &assertion.value {
484                let gleam_val = json_to_gleam(val);
485                let _ = writeln!(
486                    out,
487                    "  {field_expr} |> int.is_at_least({gleam_val}) |> should.equal(True)"
488                );
489            }
490        }
491        "less_than_or_equal" => {
492            if let Some(val) = &assertion.value {
493                let gleam_val = json_to_gleam(val);
494                let _ = writeln!(
495                    out,
496                    "  {field_expr} |> int.is_at_most({gleam_val}) |> should.equal(True)"
497                );
498            }
499        }
500        "contains_any" => {
501            if let Some(values) = &assertion.values {
502                for val in values {
503                    let gleam_val = json_to_gleam(val);
504                    let _ = writeln!(
505                        out,
506                        "  {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
507                    );
508                }
509            }
510        }
511        "matches_regex" => {
512            let _ = writeln!(out, "  // regex match not yet implemented for Gleam");
513        }
514        "method_result" => {
515            let _ = writeln!(out, "  // method_result assertions not yet implemented for Gleam");
516        }
517        other => {
518            panic!("Gleam e2e generator: unsupported assertion type: {other}");
519        }
520    }
521}
522
523/// Convert a `serde_json::Value` to a Gleam literal string.
524fn json_to_gleam(value: &serde_json::Value) -> String {
525    match value {
526        serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
527        serde_json::Value::Bool(b) => {
528            if *b {
529                "True".to_string()
530            } else {
531                "False".to_string()
532            }
533        }
534        serde_json::Value::Number(n) => n.to_string(),
535        serde_json::Value::Null => "Nil".to_string(),
536        serde_json::Value::Array(arr) => {
537            let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
538            format!("[{}]", items.join(", "))
539        }
540        serde_json::Value::Object(_) => {
541            let json_str = serde_json::to_string(value).unwrap_or_default();
542            format!("\"{}\"", escape_gleam(&json_str))
543        }
544    }
545}