use crate::config::E2eConfig;
use crate::escape::{escape_gleam, sanitize_filename, sanitize_ident};
use crate::field_access::FieldResolver;
use crate::fixture::{Assertion, Fixture, FixtureGroup, ValidationErrorExpectation};
use alef_core::backend::GeneratedFile;
use alef_core::config::ResolvedCrateConfig;
use alef_core::hash::{self, CommentStyle};
use anyhow::Result;
use heck::ToSnakeCase;
use std::collections::HashSet;
use std::fmt::Write as FmtWrite;
use std::path::PathBuf;
use super::E2eCodegen;
use super::client;
pub struct GleamE2eCodegen;
impl E2eCodegen for GleamE2eCodegen {
fn generate(
&self,
groups: &[FixtureGroup],
e2e_config: &E2eConfig,
config: &ResolvedCrateConfig,
) -> Result<Vec<GeneratedFile>> {
let lang = self.language_name();
let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
let mut files = Vec::new();
let call = &e2e_config.call;
let overrides = call.overrides.get(lang);
let module_path = overrides
.and_then(|o| o.module.as_ref())
.cloned()
.unwrap_or_else(|| call.module.clone());
let function_name = overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call.function.clone());
let result_var = &call.result_var;
let gleam_pkg = e2e_config.resolve_package("gleam");
let pkg_path = gleam_pkg
.as_ref()
.and_then(|p| p.path.as_ref())
.cloned()
.unwrap_or_else(|| "../../packages/gleam".to_string());
let pkg_name = gleam_pkg
.as_ref()
.and_then(|p| p.name.as_ref())
.cloned()
.unwrap_or_else(|| config.name.to_snake_case());
files.push(GeneratedFile {
path: output_base.join("gleam.toml"),
content: render_gleam_toml(&pkg_path, &pkg_name, e2e_config.dep_mode),
generated_header: false,
});
files.push(GeneratedFile {
path: output_base.join("src").join("e2e_gleam.gleam"),
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(),
generated_header: false,
});
let mut any_tests = false;
for group in groups {
let active: Vec<&Fixture> = group
.fixtures
.iter()
.filter(|f| super::should_include_fixture(f, lang, e2e_config))
.filter(|f| {
if let Some(http) = &f.http {
let has_upgrade = http
.request
.headers
.iter()
.any(|(k, v)| k.eq_ignore_ascii_case("upgrade") && v.eq_ignore_ascii_case("websocket"));
!has_upgrade
} else {
true
}
})
.collect();
if active.is_empty() {
continue;
}
let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
let field_resolver = FieldResolver::new(
&e2e_config.fields,
&e2e_config.fields_optional,
&e2e_config.result_fields,
&e2e_config.fields_array,
&HashSet::new(),
);
let content = render_test_file(
&group.category,
&active,
e2e_config,
&module_path,
&function_name,
result_var,
&e2e_config.call.args,
&field_resolver,
&e2e_config.fields_enum,
);
files.push(GeneratedFile {
path: output_base.join("test").join(filename),
content,
generated_header: true,
});
any_tests = true;
}
let entry = if any_tests {
concat!(
"// Generated by alef. Do not edit by hand.\n",
"import gleeunit\n",
"\n",
"pub fn main() {\n",
" gleeunit.main()\n",
"}\n",
)
.to_string()
} else {
concat!(
"// Generated by alef. Do not edit by hand.\n",
"// No fixture-driven tests for Gleam — e2e tests require HTTP fixtures\n",
"// or non-HTTP fixtures with gleam-specific call overrides.\n",
"import gleeunit\n",
"import gleeunit/should\n",
"\n",
"pub fn main() {\n",
" gleeunit.main()\n",
"}\n",
"\n",
"pub fn compilation_smoke_test() {\n",
" True |> should.equal(True)\n",
"}\n",
)
.to_string()
};
files.push(GeneratedFile {
path: output_base.join("test").join("e2e_gleam_test.gleam"),
content: entry,
generated_header: false,
});
Ok(files)
}
fn language_name(&self) -> &'static str {
"gleam"
}
}
fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
use alef_core::template_versions::hex;
let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
let gleam_httpc = hex::GLEAM_HTTPC_VERSION_RANGE;
let envoy = hex::ENVOY_VERSION_RANGE;
let deps = match dep_mode {
crate::config::DependencyMode::Registry => {
format!(
r#"{pkg_name} = ">= 0.1.0"
gleam_stdlib = "{stdlib}"
gleeunit = "{gleeunit}"
gleam_httpc = "{gleam_httpc}"
gleam_http = ">= 4.0.0 and < 5.0.0"
envoy = "{envoy}""#
)
}
crate::config::DependencyMode::Local => {
format!(
r#"{pkg_name} = {{ path = "{pkg_path}" }}
gleam_stdlib = "{stdlib}"
gleeunit = "{gleeunit}"
gleam_httpc = "{gleam_httpc}"
gleam_http = ">= 4.0.0 and < 5.0.0"
envoy = "{envoy}""#
)
}
};
format!(
r#"name = "e2e_gleam"
version = "0.1.0"
target = "erlang"
[dependencies]
{deps}
"#
)
}
#[allow(clippy::too_many_arguments)]
fn render_test_file(
_category: &str,
fixtures: &[&Fixture],
e2e_config: &E2eConfig,
module_path: &str,
function_name: &str,
result_var: &str,
args: &[crate::config::ArgMapping],
field_resolver: &FieldResolver,
enum_fields: &HashSet<String>,
) -> String {
let mut out = String::new();
out.push_str(&hash::header(CommentStyle::DoubleSlash));
let _ = writeln!(out, "import gleeunit");
let _ = writeln!(out, "import gleeunit/should");
let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
if has_http_fixtures {
let _ = writeln!(out, "import gleam/httpc");
let _ = writeln!(out, "import gleam/http");
let _ = writeln!(out, "import gleam/http/request");
let _ = writeln!(out, "import gleam/list");
let _ = writeln!(out, "import gleam/result");
let _ = writeln!(out, "import gleam/string");
let _ = writeln!(out, "import envoy");
}
let has_non_http_with_override = fixtures.iter().any(|f| !f.is_http_test());
if has_non_http_with_override {
let _ = writeln!(out, "import {module_path}");
}
let _ = writeln!(out);
let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
for fixture in fixtures {
if fixture.is_http_test() {
continue; }
for assertion in &fixture.assertions {
match assertion.assertion_type.as_str() {
"contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" | "min_length"
| "max_length" | "contains_any" => {
needed_modules.insert("string");
}
"not_empty" | "is_empty" | "count_min" | "count_equals" => {
needed_modules.insert("list");
}
"greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
needed_modules.insert("int");
}
_ => {}
}
}
}
for module in &needed_modules {
let _ = writeln!(out, "import gleam/{module}");
}
if !needed_modules.is_empty() {
let _ = writeln!(out);
}
for fixture in fixtures {
if fixture.is_http_test() {
render_http_test_case(&mut out, fixture);
} else {
render_test_case(
&mut out,
fixture,
e2e_config,
module_path,
function_name,
result_var,
args,
field_resolver,
enum_fields,
);
}
let _ = writeln!(out);
}
out
}
struct GleamTestClientRenderer;
impl client::TestClientRenderer for GleamTestClientRenderer {
fn language_name(&self) -> &'static str {
"gleam"
}
fn sanitize_test_name(&self, id: &str) -> String {
let raw = sanitize_ident(id);
let stripped = raw.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
if stripped.is_empty() { raw } else { stripped.to_string() }
}
fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
let _ = writeln!(out, "// {description}");
let _ = writeln!(out, "pub fn {fn_name}_test() {{");
if let Some(reason) = skip_reason {
let escaped = escape_gleam(reason);
let _ = writeln!(out, " // skipped: {escaped}");
let _ = writeln!(out, " Nil");
}
}
fn render_test_close(&self, out: &mut String) {
let _ = writeln!(out, "}}");
}
fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
let path = ctx.path;
let _ = writeln!(out, " let base_url = case envoy.get(\"MOCK_SERVER_URL\") {{");
let _ = writeln!(out, " Ok(u) -> u");
let _ = writeln!(out, " Error(_) -> \"http://localhost:8080\"");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " let assert Ok(req) = request.to(base_url <> \"{path}\")");
let method_const = match ctx.method.to_uppercase().as_str() {
"GET" => "Get",
"POST" => "Post",
"PUT" => "Put",
"DELETE" => "Delete",
"PATCH" => "Patch",
"HEAD" => "Head",
"OPTIONS" => "Options",
_ => "Post",
};
let _ = writeln!(out, " let req = request.set_method(req, http.{method_const})");
if ctx.body.is_some() {
let content_type = ctx.content_type.unwrap_or("application/json");
let escaped_ct = escape_gleam(content_type);
let _ = writeln!(
out,
" let req = request.set_header(req, \"content-type\", \"{escaped_ct}\")"
);
}
for (name, value) in ctx.headers {
let lower = name.to_lowercase();
if matches!(lower.as_str(), "content-length" | "host" | "transfer-encoding") {
continue;
}
let escaped_name = escape_gleam(name);
let escaped_value = escape_gleam(value);
let _ = writeln!(
out,
" let req = request.set_header(req, \"{escaped_name}\", \"{escaped_value}\")"
);
}
if !ctx.cookies.is_empty() {
let cookie_str: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
let escaped_cookie = escape_gleam(&cookie_str.join("; "));
let _ = writeln!(
out,
" let req = request.set_header(req, \"cookie\", \"{escaped_cookie}\")"
);
}
if let Some(body) = ctx.body {
let json_str = serde_json::to_string(body).unwrap_or_default();
let escaped = escape_gleam(&json_str);
let _ = writeln!(out, " let req = request.set_body(req, \"{escaped}\")");
}
let resp = ctx.response_var;
let _ = writeln!(out, " let assert Ok({resp}) = httpc.send(req)");
}
fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
let _ = writeln!(out, " {response_var}.status |> should.equal({status})");
}
fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
let escaped_name = escape_gleam(&name.to_lowercase());
match expected {
"<<absent>>" => {
let _ = writeln!(
out,
" {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_false()"
);
}
"<<present>>" | "<<uuid>>" => {
let _ = writeln!(
out,
" {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_true()"
);
}
literal => {
let _escaped_value = escape_gleam(literal);
let _ = writeln!(
out,
" {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_true()"
);
}
}
}
fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
let escaped = match expected {
serde_json::Value::String(s) => escape_gleam(s),
other => escape_gleam(&serde_json::to_string(other).unwrap_or_default()),
};
let _ = writeln!(
out,
" {response_var}.body |> string.trim |> should.equal(\"{escaped}\")"
);
}
fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
if let Some(obj) = expected.as_object() {
for (key, val) in obj {
let fragment = escape_gleam(&format!("\"{}\":", key));
let _ = writeln!(
out,
" {response_var}.body |> string.contains(\"{fragment}\") |> should.equal(True)"
);
let _ = val; }
}
}
fn render_assert_validation_errors(
&self,
out: &mut String,
response_var: &str,
errors: &[ValidationErrorExpectation],
) {
for err in errors {
let escaped_msg = escape_gleam(&err.msg);
let _ = writeln!(
out,
" {response_var}.body |> string.contains(\"{escaped_msg}\") |> should.equal(True)"
);
}
}
}
fn render_http_test_case(out: &mut String, fixture: &Fixture) {
client::http_call::render_http_test(out, &GleamTestClientRenderer, fixture);
}
#[allow(clippy::too_many_arguments)]
fn render_test_case(
out: &mut String,
fixture: &Fixture,
e2e_config: &E2eConfig,
module_path: &str,
_function_name: &str,
_result_var: &str,
_args: &[crate::config::ArgMapping],
field_resolver: &FieldResolver,
enum_fields: &HashSet<String>,
) {
let call_config = e2e_config.resolve_call(fixture.call.as_deref());
let lang = "gleam";
let call_overrides = call_config.overrides.get(lang);
let function_name = call_overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call_config.function.clone());
let result_var = &call_config.result_var;
let args = &call_config.args;
let raw_name = sanitize_ident(&fixture.id);
let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
let test_name = if stripped.is_empty() {
raw_name.as_str()
} else {
stripped
};
let description = &fixture.description;
let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
let _ = writeln!(out, "// {description}");
let _ = writeln!(out, "pub fn {test_name}_test() {{");
for line in &setup_lines {
let _ = writeln!(out, " {line}");
}
if expects_error {
let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
let _ = writeln!(out, "}}");
return;
}
let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
let _ = writeln!(out, " {result_var} |> should.be_ok()");
let _ = writeln!(out, " let assert Ok(r) = {result_var}");
for assertion in &fixture.assertions {
render_assertion(out, assertion, "r", field_resolver, enum_fields);
}
let _ = writeln!(out, "}}");
}
fn build_args_and_setup(
input: &serde_json::Value,
args: &[crate::config::ArgMapping],
fixture_id: &str,
) -> (Vec<String>, String) {
if args.is_empty() {
return (Vec::new(), String::new());
}
let mut setup_lines: Vec<String> = Vec::new();
let mut parts: Vec<String> = Vec::new();
for arg in args {
if arg.arg_type == "mock_url" {
setup_lines.push(format!(
"let {} = (import \"os\" as os).get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
arg.name,
));
parts.push(arg.name.clone());
continue;
}
let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
let val = input.get(field);
match val {
None | Some(serde_json::Value::Null) if arg.optional => {
continue;
}
None | Some(serde_json::Value::Null) => {
let default_val = match arg.arg_type.as_str() {
"string" => "\"\"".to_string(),
"int" | "integer" => "0".to_string(),
"float" | "number" => "0.0".to_string(),
"bool" | "boolean" => "False".to_string(),
_ => "Nil".to_string(),
};
parts.push(default_val);
}
Some(v) => {
parts.push(json_to_gleam(v));
}
}
}
(setup_lines, parts.join(", "))
}
fn render_assertion(
out: &mut String,
assertion: &Assertion,
result_var: &str,
field_resolver: &FieldResolver,
enum_fields: &HashSet<String>,
) {
if let Some(f) = &assertion.field {
if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
return;
}
}
let _field_is_enum = assertion
.field
.as_deref()
.is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
let field_expr = match &assertion.field {
Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
_ => result_var.to_string(),
};
match assertion.assertion_type.as_str() {
"equals" => {
if let Some(expected) = &assertion.value {
let gleam_val = json_to_gleam(expected);
let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
}
}
"contains" => {
if let Some(expected) = &assertion.value {
let gleam_val = json_to_gleam(expected);
let _ = writeln!(
out,
" {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
);
}
}
"contains_all" => {
if let Some(values) = &assertion.values {
for val in values {
let gleam_val = json_to_gleam(val);
let _ = writeln!(
out,
" {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
);
}
}
}
"not_contains" => {
if let Some(expected) = &assertion.value {
let gleam_val = json_to_gleam(expected);
let _ = writeln!(
out,
" {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
);
}
}
"not_empty" => {
let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
}
"is_empty" => {
let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
}
"starts_with" => {
if let Some(expected) = &assertion.value {
let gleam_val = json_to_gleam(expected);
let _ = writeln!(
out,
" {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
);
}
}
"ends_with" => {
if let Some(expected) = &assertion.value {
let gleam_val = json_to_gleam(expected);
let _ = writeln!(
out,
" {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
);
}
}
"min_length" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(
out,
" {field_expr} |> string.length |> int.is_at_least({n}) |> should.equal(True)"
);
}
}
}
"max_length" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(
out,
" {field_expr} |> string.length |> int.is_at_most({n}) |> should.equal(True)"
);
}
}
}
"count_min" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(
out,
" {field_expr} |> list.length |> int.is_at_least({n}) |> should.equal(True)"
);
}
}
}
"count_equals" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
}
}
}
"is_true" => {
let _ = writeln!(out, " {field_expr} |> should.equal(True)");
}
"is_false" => {
let _ = writeln!(out, " {field_expr} |> should.equal(False)");
}
"not_error" => {
}
"error" => {
}
"greater_than" => {
if let Some(val) = &assertion.value {
let gleam_val = json_to_gleam(val);
let _ = writeln!(
out,
" {field_expr} |> int.is_strictly_greater_than({gleam_val}) |> should.equal(True)"
);
}
}
"less_than" => {
if let Some(val) = &assertion.value {
let gleam_val = json_to_gleam(val);
let _ = writeln!(
out,
" {field_expr} |> int.is_strictly_less_than({gleam_val}) |> should.equal(True)"
);
}
}
"greater_than_or_equal" => {
if let Some(val) = &assertion.value {
let gleam_val = json_to_gleam(val);
let _ = writeln!(
out,
" {field_expr} |> int.is_at_least({gleam_val}) |> should.equal(True)"
);
}
}
"less_than_or_equal" => {
if let Some(val) = &assertion.value {
let gleam_val = json_to_gleam(val);
let _ = writeln!(
out,
" {field_expr} |> int.is_at_most({gleam_val}) |> should.equal(True)"
);
}
}
"contains_any" => {
if let Some(values) = &assertion.values {
for val in values {
let gleam_val = json_to_gleam(val);
let _ = writeln!(
out,
" {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
);
}
}
}
"matches_regex" => {
let _ = writeln!(out, " // regex match not yet implemented for Gleam");
}
"method_result" => {
let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
}
other => {
panic!("Gleam e2e generator: unsupported assertion type: {other}");
}
}
}
fn json_to_gleam(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
serde_json::Value::Bool(b) => {
if *b {
"True".to_string()
} else {
"False".to_string()
}
}
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => "Nil".to_string(),
serde_json::Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
format!("[{}]", items.join(", "))
}
serde_json::Value::Object(_) => {
let json_str = serde_json::to_string(value).unwrap_or_default();
format!("\"{}\"", escape_gleam(&json_str))
}
}
}