//! Gleam e2e test generator using gleeunit/should.
//!
//! Generates `packages/gleam/test/<crate>_test.gleam` files from JSON fixtures.
//! HTTP fixtures hit the mock server at `MOCK_SERVER_URL/fixtures/<id>` using
//! the `gleam_httpc` HTTP client library. Non-HTTP fixtures without a gleam-specific
//! call override emit a skip stub.
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::{ToPascalCase, ToSnakeCase};
use std::collections::HashSet;
use std::fmt::Write as FmtWrite;
use std::path::PathBuf;
use super::E2eCodegen;
use super::client;
/// Gleam e2e code generator.
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();
// Resolve call config with overrides.
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;
// Resolve package config.
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());
// Generate gleam.toml.
files.push(GeneratedFile {
path: output_base.join("gleam.toml"),
content: render_gleam_toml(&pkg_path, &pkg_name, e2e_config.dep_mode),
generated_header: false,
});
// Gleam requires a `src/` directory even for test-only projects.
// Emit a helper module with `read_file_bytes` external for loading test
// documents as BitArray at runtime.
let e2e_helpers = concat!(
"// Generated by alef. Do not edit by hand.\n",
"// E2e helper module — provides file-reading utilities for Gleam tests.\n",
"import gleam/dynamic\n",
"\n",
"/// Read a file into a BitArray via the Erlang :file module.\n",
"/// The path is relative to the e2e working directory when `gleam test` runs.\n",
"@external(erlang, \"file\", \"read_file\")\n",
"pub fn read_file_bytes(path: String) -> Result(BitArray, dynamic.Dynamic)\n",
"\n",
"/// Ensure the kreuzberg OTP application and all its dependencies are started.\n",
"/// This is required when running `gleam test` outside of `mix test`, since the\n",
"/// Rustler NIF init hook needs the :kreuzberg application to be started before\n",
"/// any Kreuzberg.Native functions can be called.\n",
"/// Calls the Erlang shim e2e_startup:start_kreuzberg/0.\n",
"@external(erlang, \"e2e_startup\", \"start_kreuzberg\")\n",
"pub fn start_kreuzberg() -> Nil\n",
);
// Erlang shim module that starts the kreuzberg OTP application and all deps.
// Compiled alongside the Gleam source when gleam test is run.
// Must start elixir first (provides Elixir.Application used by Rustler NIF init),
// then ensure kreuzberg and all its transitive OTP dependencies are running.
let erlang_startup = concat!(
"%% Generated by alef. Do not edit by hand.\n",
"%% Starts the kreuzberg OTP application and all its dependencies.\n",
"%% Called by e2e_gleam_test.main/0 before gleeunit.main/0.\n",
"-module(e2e_startup).\n",
"-export([start_kreuzberg/0]).\n",
"\n",
"start_kreuzberg() ->\n",
" %% Elixir runtime must be started before kreuzberg NIF init\n",
" %% because Rustler uses Elixir.Application.app_dir/2 to locate the .so.\n",
" {ok, _} = application:ensure_all_started(elixir),\n",
" {ok, _} = application:ensure_all_started(kreuzberg),\n",
" nil.\n",
);
files.push(GeneratedFile {
path: output_base.join("src").join("e2e_gleam.gleam"),
content: e2e_helpers.to_string(),
generated_header: false,
});
files.push(GeneratedFile {
path: output_base.join("src").join("e2e_startup.erl"),
content: erlang_startup.to_string(),
generated_header: false,
});
// Track whether any test file was emitted.
let mut any_tests = false;
// Generate test files per category.
for group in groups {
let active: Vec<&Fixture> = group
.fixtures
.iter()
// Include both HTTP and non-HTTP fixtures. Filter out those marked as skip.
.filter(|f| super::should_include_fixture(f, lang, e2e_config))
// gleam_httpc cannot follow HTTP/1.1 protocol upgrades (101 Switching
// Protocols), so skip WebSocket-upgrade fixtures whose request advertises
// Upgrade: websocket. The server returns 101 and gleam_httpc times out.
.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
}
})
// For non-HTTP fixtures, include all (will use default or override call config).
// Gleam always has a call override or can use the default call config.
.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,
&e2e_config.fields_method_calls,
);
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;
}
// Always emit the gleeunit entry module — `gleam test` invokes
// `<package>_test.main()` to discover and run all `_test.gleam` files.
// When no fixture-driven tests exist, also include a tiny smoke test so
// the suite is non-empty.
let entry = if any_tests {
concat!(
"// Generated by alef. Do not edit by hand.\n",
"import gleeunit\n",
"import e2e_gleam\n",
"\n",
"pub fn main() {\n",
" let _ = e2e_gleam.start_kreuzberg()\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"
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
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");
// Check if any fixture is HTTP-based.
let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
// Import HTTP client for HTTP fixtures.
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");
}
// Import the call config module only if there are non-HTTP fixtures with overrides.
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, "import e2e_gleam");
}
let _ = writeln!(out);
// Track which modules we need to import based on assertions used (non-HTTP tests).
let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
// First pass: determine which helper modules we need.
for fixture in fixtures {
if fixture.is_http_test() {
continue; // Skip HTTP fixtures for assertion analysis.
}
// Determine if any args use `bytes` arg type — requires e2e_gleam file reader.
let call_config = e2e_config.resolve_call(fixture.call.as_deref());
let has_bytes_arg = call_config.args.iter().any(|a| a.arg_type == "bytes");
// Optional string args emit option.Some(...)/option.None — need option import.
let has_optional_string_arg = call_config.args.iter().any(|a| a.arg_type == "string" && a.optional);
// json_object args emit option.None in ExtractionConfig and BatchItem constructors.
let has_json_object_arg = call_config.args.iter().any(|a| a.arg_type == "json_object");
if has_bytes_arg || has_optional_string_arg || has_json_object_arg {
needed_modules.insert("option");
}
for assertion in &fixture.assertions {
// When a field traverses a tagged-union variant, we emit a case expression
// that requires `option` for unwrapping the Option(FormatMetadata) wrapper.
let needs_case_expr = assertion
.field
.as_deref()
.is_some_and(|f| field_resolver.tagged_union_split(f).is_some());
if needs_case_expr {
needed_modules.insert("option");
}
// Optional field equality comparisons wrap in option.Some(...).
if let Some(f) = &assertion.field {
if field_resolver.is_optional(f) {
needed_modules.insert("option");
}
}
match assertion.assertion_type.as_str() {
"contains_any" => {
// contains_any always generates list.any(...) + string.contains(...) — needs both.
needed_modules.insert("string");
needed_modules.insert("list");
}
"contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" => {
needed_modules.insert("string");
// `contains` on an array field emits list.any — also need `list`.
if let Some(f) = &assertion.field {
let resolved = field_resolver.resolve(f);
if field_resolver.is_array(f) || field_resolver.is_array(resolved) {
needed_modules.insert("list");
}
} else {
// No field → assertion on root result; if result_is_array, need list.
if call_config.result_is_array
|| call_config.result_is_vec
|| field_resolver.is_array("")
|| field_resolver.is_array(field_resolver.resolve(""))
{
needed_modules.insert("list");
}
}
}
"not_empty" | "is_empty" | "count_min" | "count_equals" => {
needed_modules.insert("list");
// Note: count_min/count_equals use fn(n__) { n__ >= N } — no gleam/int import needed.
}
"min_length" | "max_length" => {
needed_modules.insert("string");
// Note: min_length/max_length use fn(n__) { n__ >= N } — no gleam/int import needed.
}
"greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
// Uses fn(n__) { n__ >= N } inline — no gleam/int import needed.
}
_ => {}
}
// When an array field is accessed inside a tagged-union case block, list is needed.
if needs_case_expr {
if let Some(f) = &assertion.field {
let resolved = field_resolver.resolve(f);
if field_resolver.is_array(resolved) {
needed_modules.insert("list");
}
}
}
// When an assertion uses optional-prefix patterns (e.g. document.nodes),
// both list and int (and option) may be needed.
if let Some(f) = &assertion.field {
if !f.is_empty() {
let parts: Vec<&str> = f.split('.').collect();
let has_opt_prefix = (1..parts.len()).any(|i| {
let prefix_path = parts[..i].join(".");
field_resolver.is_optional(&prefix_path)
});
if has_opt_prefix {
needed_modules.insert("option");
}
}
}
}
}
// Emit additional imports.
for module in &needed_modules {
let _ = writeln!(out, "import gleam/{module}");
}
if !needed_modules.is_empty() {
let _ = writeln!(out);
}
// Each fixture becomes its own test function.
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
}
/// Gleam HTTP test renderer using `gleam_httpc` against `MOCK_SERVER_URL`.
///
/// Satisfies [`client::TestClientRenderer`] so the shared
/// [`client::http_call::render_http_test`] driver drives the call sequence.
struct GleamTestClientRenderer;
impl client::TestClientRenderer for GleamTestClientRenderer {
fn language_name(&self) -> &'static str {
"gleam"
}
/// Gleam identifiers must start with a lowercase letter, not `_` or a digit.
/// Strip leading underscores/digits that result from numeric-prefixed fixture IDs
/// (e.g. `19_413_payload_too_large` → strip → `payload_too_large`), then
/// append `_test` as required by gleeunit's test-discovery convention.
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() }
}
/// Emit `// {description}\npub fn {fn_name}_test() {`.
///
/// gleeunit discovers tests as top-level `pub fn <name>_test()` functions.
/// Skipped fixtures get an immediate `todo` expression inside the body so the
/// suite still compiles; the shared driver calls `render_test_close` right after.
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 {
// Gleam has no built-in skip mechanism; emit a comment + immediate return
// so the test compiles but is visually marked as skipped.
let escaped = escape_gleam(reason);
let _ = writeln!(out, " // skipped: {escaped}");
let _ = writeln!(out, " Nil");
}
}
/// Emit the closing `}` for the test function.
fn render_test_close(&self, out: &mut String) {
let _ = writeln!(out, "}}");
}
/// Emit a `gleam_httpc` request to `MOCK_SERVER_URL` + `ctx.path`.
///
/// Uses `envoy.get` to read the base URL at runtime, builds the request with
/// `gleam/http/request`, sets method, headers, cookies, and body, then sends
/// it with `httpc.send`. The response is bound to `ctx.response_var` (`resp`).
fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
let path = ctx.path;
// Read base URL from environment.
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, " }}");
// Build the request struct from the URL.
let _ = writeln!(out, " let assert Ok(req) = request.to(base_url <> \"{path}\")");
// Set HTTP method.
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})");
// Set Content-Type when a body is present.
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}\")"
);
}
// Set additional request headers.
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}\")"
);
}
// Merge cookies into a single `Cookie` header.
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}\")"
);
}
// Set body when present.
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}\")");
}
// Send the request; bind the response.
let resp = ctx.response_var;
let _ = writeln!(out, " let assert Ok({resp}) = httpc.send(req)");
}
/// Emit `resp.status |> should.equal(status)`.
fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
let _ = writeln!(out, " {response_var}.status |> should.equal({status})");
}
/// Emit a header presence check via `list.find`.
///
/// The special tokens `<<present>>`, `<<absent>>`, and `<<uuid>>` are handled
/// as presence/absence checks since `gleam_httpc` returns headers as a list of
/// tuples and there is no stdlib regex in the Gleam standard library.
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>>" => {
// uuid token: check for presence only (no stdlib regex available).
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 => {
// For exact values, verify the header is present (value matching
// requires a custom find; presence is the meaningful assertion here).
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()"
);
}
}
}
/// Emit `resp.body |> string.trim |> should.equal("...")`.
///
/// Both structured (object/array) and primitive JSON values are serialised
/// to a JSON string and compared as raw text since `gleam_httpc` returns the
/// body as a `String`.
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}\")"
);
}
/// Emit partial body assertions.
///
/// `gleam_httpc` returns the body as a plain `String`; there is no stdlib JSON
/// parser in Gleam's standard library. A `string.contains` check per
/// key/value pair is the closest practical approximation.
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; // value-level matching requires a JSON library not in stdlib
}
}
}
/// Emit validation-error assertions by checking the raw body string for each
/// expected error message.
///
/// `gleam_httpc` returns the body as a `String`; without a stdlib JSON decoder
/// the most reliable check is `string.contains` on the serialised message.
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)"
);
}
}
}
/// Render an HTTP server test using `gleam_httpc` against `MOCK_SERVER_URL`.
///
/// Delegates to [`client::http_call::render_http_test`] via the shared driver.
/// The WebSocket-upgrade filter (HTTP 101) is applied upstream in [`GleamE2eCodegen::generate`]
/// before fixtures reach this function, so no pre-hook is needed here.
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>,
) {
// Resolve per-fixture call config.
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;
// Gleam identifiers must start with a lowercase letter, not `_` or a digit.
// Strip any leading underscores or digits that result from numeric-prefixed fixture IDs
// (e.g. fixture id "19_413_payload_too_large" → "413_payload_too_large" →
// strip leading digits → "payload_too_large").
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);
// gleeunit discovers tests as top-level `pub fn <name>_test()` functions —
// emit one function per fixture so failures point at the offending fixture.
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}");
let result_is_array = call_config.result_is_array || call_config.result_is_vec;
for assertion in &fixture.assertions {
render_assertion(out, assertion, "r", field_resolver, enum_fields, result_is_array);
}
let _ = writeln!(out, "}}");
}
/// Build setup lines and the argument list for the function call.
///
/// Gleam is statically typed, so each arg type must produce a correctly-typed expression:
/// - `file_path` → quoted string literal
/// - `bytes` → setup: `let assert Ok(data__) = e2e_gleam.read_file_bytes("../../test_documents/<path>")`
/// arg: `data__`
/// - `string` + optional → `option.Some("value")` or `option.None`
/// - `string` non-optional → `"value"`
/// - `json_object` with element_type (batch list) → `[kreuzberg.BatchFileItem(...), ...]` or `[kreuzberg.BatchBytesItem(...), ...]`
/// - `json_object` (config) → `build_gleam_extraction_config(val)`
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();
let mut bytes_var_counter = 0usize;
for arg in args {
let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
let val = input.get(field);
match arg.arg_type.as_str() {
"file_path" => {
// Always a required string path.
// Gleam e2e runs from e2e/gleam/ so prefix with ../../test_documents/
let path = val.and_then(|v| v.as_str()).unwrap_or("");
let full_path = format!("../../test_documents/{path}");
parts.push(format!("\"{}\"", escape_gleam(&full_path)));
}
"bytes" => {
// Read the file at runtime via Erlang file:read_file/1.
// The fixture `data` field holds the path relative to test_documents/.
let path = val.and_then(|v| v.as_str()).unwrap_or("");
let var_name = if bytes_var_counter == 0 {
"data_bytes__".to_string()
} else {
format!("data_bytes_{bytes_var_counter}__")
};
bytes_var_counter += 1;
// Use relative path from e2e/gleam/ project root.
let full_path = format!("../../test_documents/{path}");
setup_lines.push(format!(
"let assert Ok({var_name}) = e2e_gleam.read_file_bytes(\"{}\")",
escape_gleam(&full_path)
));
parts.push(var_name);
}
"string" if arg.optional => {
// Optional string: emit option.Some("value") or option.None.
match val {
None | Some(serde_json::Value::Null) => {
parts.push("option.None".to_string());
}
Some(serde_json::Value::String(s)) if s.is_empty() => {
parts.push("option.None".to_string());
}
Some(serde_json::Value::String(s)) => {
parts.push(format!("option.Some(\"{}\")", escape_gleam(s)));
}
Some(v) => {
parts.push(format!("option.Some({})", json_to_gleam(v)));
}
}
}
"string" => {
// Non-optional string.
match val {
None | Some(serde_json::Value::Null) => {
parts.push("\"\"".to_string());
}
Some(serde_json::Value::String(s)) => {
parts.push(format!("\"{}\"", escape_gleam(s)));
}
Some(v) => {
parts.push(json_to_gleam(v));
}
}
}
"json_object" => {
// Determine element_type to decide batch list vs. config.
let element_type = arg.element_type.as_deref().unwrap_or("");
match element_type {
"BatchFileItem" => {
// Emit a Gleam list of kreuzberg.BatchFileItem(path: "...", config: option.None)
// Gleam e2e runs from e2e/gleam/ so relative paths need ../../test_documents/ prefix.
let items_expr = match val {
Some(serde_json::Value::Array(arr)) => {
let items: Vec<String> = arr
.iter()
.map(|item| {
let path = item.get("path").and_then(|v| v.as_str()).unwrap_or("");
// Absolute paths (starting with /) are used as-is.
// Relative paths need the test_documents prefix.
let full_path = if path.starts_with('/') {
path.to_string()
} else {
format!("../../test_documents/{path}")
};
format!(
"kreuzberg.BatchFileItem(path: \"{}\", config: option.None)",
escape_gleam(&full_path)
)
})
.collect();
format!("[{}]", items.join(", "))
}
_ => "[]".to_string(),
};
if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
parts.push("[]".to_string());
} else {
parts.push(items_expr);
}
}
"BatchBytesItem" => {
// Emit a Gleam list of kreuzberg.BatchBytesItem(content: <<...>>, mime_type: "...", config: option.None)
let items_expr = match val {
Some(serde_json::Value::Array(arr)) => {
let items: Vec<String> = arr
.iter()
.map(|item| {
let content = item
.get("content")
.and_then(|v| v.as_array())
.map(|bytes| {
let byte_strs: Vec<String> = bytes
.iter()
.map(|b| b.as_u64().unwrap_or(0).to_string())
.collect();
format!("<<{}>>", byte_strs.join(", "))
})
.unwrap_or_else(|| "<<>>".to_string());
let mime_type = item
.get("mime_type")
.and_then(|v| v.as_str())
.unwrap_or("text/plain");
format!(
"kreuzberg.BatchBytesItem(content: {content}, mime_type: \"{}\", config: option.None)",
escape_gleam(mime_type)
)
})
.collect();
format!("[{}]", items.join(", "))
}
_ => "[]".to_string(),
};
if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
parts.push("[]".to_string());
} else {
parts.push(items_expr);
}
}
_ => {
// Config object or empty optional config.
if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
// Config is always required for Gleam (not Option), emit default.
parts.push(build_gleam_default_extraction_config());
} else {
let empty_obj = serde_json::Value::Object(Default::default());
let config_val = val.unwrap_or(&empty_obj);
parts.push(build_gleam_extraction_config(config_val));
}
}
}
}
"int" | "integer" => match val {
None | Some(serde_json::Value::Null) if arg.optional => {}
None | Some(serde_json::Value::Null) => parts.push("0".to_string()),
Some(v) => parts.push(json_to_gleam(v)),
},
"bool" | "boolean" => match val {
Some(serde_json::Value::Bool(true)) => parts.push("True".to_string()),
Some(serde_json::Value::Bool(false)) | None | Some(serde_json::Value::Null) => {
if !arg.optional {
parts.push("False".to_string());
}
}
Some(v) => parts.push(json_to_gleam(v)),
},
_ => {
// Fallback for unknown types.
match val {
None | Some(serde_json::Value::Null) if arg.optional => {}
None | Some(serde_json::Value::Null) => parts.push("Nil".to_string()),
Some(v) => parts.push(json_to_gleam(v)),
}
}
}
}
(setup_lines, parts.join(", "))
}
/// Build the default ExtractionConfig Gleam constructor (all fields at default values).
fn build_gleam_default_extraction_config() -> String {
build_gleam_extraction_config(&serde_json::Value::Object(Default::default()))
}
/// Build an ExtractionConfig Gleam constructor, overriding defaults with values from the config JSON.
///
/// Gleam's ExtractionConfig is a struct constructor with all fields named.
/// Default values:
/// - Bool fields: False (use_cache=True, enable_quality_processing=True)
/// - Option fields: option.None
/// - OutputFormat: kreuzberg.Plain
/// - ResultFormat: kreuzberg.Unified
/// - max_archive_depth: 10
fn build_gleam_extraction_config(config: &serde_json::Value) -> String {
let obj = config.as_object();
let get_bool = |key: &str, default: bool| -> &'static str {
if obj
.and_then(|o| o.get(key))
.and_then(|v| v.as_bool())
.unwrap_or(default)
{
"True"
} else {
"False"
}
};
let get_opt_int = |key: &str| -> String {
obj.and_then(|o| o.get(key))
.and_then(|v| v.as_i64())
.map(|n| format!("option.Some({n})"))
.unwrap_or_else(|| "option.None".to_string())
};
let get_opt_str = |key: &str| -> String {
obj.and_then(|o| o.get(key))
.and_then(|v| v.as_str())
.map(|s| format!("option.Some(\"{}\")", escape_gleam(s)))
.unwrap_or_else(|| "option.None".to_string())
};
let get_int =
|key: &str, default: i64| -> i64 { obj.and_then(|o| o.get(key)).and_then(|v| v.as_i64()).unwrap_or(default) };
// output_format: string → Gleam constructor
let output_format = obj
.and_then(|o| o.get("output_format"))
.and_then(|v| v.as_str())
.map(|s| match s {
"markdown" => "kreuzberg.OutputFormatMarkdown",
"html" => "kreuzberg.OutputFormatHtml",
"djot" => "kreuzberg.Djot",
"json" => "kreuzberg.Json",
"structured" => "kreuzberg.Structured",
"plain" | "" => "kreuzberg.Plain",
_ => "kreuzberg.Plain",
})
.unwrap_or("kreuzberg.Plain");
// security_limits: optional object → kreuzberg.SecurityLimits(...)
let security_limits = obj
.and_then(|o| o.get("security_limits"))
.and_then(|v| v.as_object())
.map(|sl| {
let get_sl_int = |k: &str, def: i64| -> i64 {
sl.get(k).and_then(|v| v.as_i64()).unwrap_or(def)
};
format!(
"option.Some(kreuzberg.SecurityLimits(max_archive_size: {}, max_compression_ratio: {}, max_files_in_archive: {}, max_nesting_depth: {}, max_entity_length: {}, max_content_size: {}, max_iterations: {}, max_xml_depth: {}, max_table_cells: {}))",
get_sl_int("max_archive_size", 524_288_000),
get_sl_int("max_compression_ratio", 100),
get_sl_int("max_files_in_archive", 10_000),
get_sl_int("max_nesting_depth", 10),
get_sl_int("max_entity_length", 8_192),
get_sl_int("max_content_size", 104_857_600),
get_sl_int("max_iterations", 1_000_000),
get_sl_int("max_xml_depth", 100),
get_sl_int("max_table_cells", 10_000),
)
})
.unwrap_or_else(|| "option.None".to_string());
let use_cache = get_bool("use_cache", true);
let enable_quality = get_bool("enable_quality_processing", true);
let force_ocr = get_bool("force_ocr", false);
let disable_ocr = get_bool("disable_ocr", false);
let include_doc_struct = get_bool("include_document_structure", false);
let max_archive_depth = get_int("max_archive_depth", 10);
let extraction_timeout_secs = get_opt_int("extraction_timeout_secs");
let concurrency_str = get_opt_str("concurrency");
let cache_namespace = get_opt_str("cache_namespace");
let cache_ttl_secs = get_opt_int("cache_ttl_secs");
let max_concurrent = get_opt_int("max_concurrent_extractions");
let html_options = get_opt_str("html_options");
format!(
"kreuzberg.ExtractionConfig(use_cache: {use_cache}, enable_quality_processing: {enable_quality}, ocr: option.None, force_ocr: {force_ocr}, force_ocr_pages: option.None, disable_ocr: {disable_ocr}, chunking: option.None, content_filter: option.None, images: option.None, pdf_options: option.None, token_reduction: option.None, language_detection: option.None, pages: option.None, keywords: option.None, postprocessor: option.None, html_options: {html_options}, html_output: option.None, extraction_timeout_secs: {extraction_timeout_secs}, max_concurrent_extractions: {max_concurrent}, result_format: kreuzberg.Unified, security_limits: {security_limits}, output_format: {output_format}, layout: option.None, include_document_structure: {include_doc_struct}, acceleration: option.None, cache_namespace: {cache_namespace}, cache_ttl_secs: {cache_ttl_secs}, email: option.None, concurrency: {concurrency_str}, max_archive_depth: {max_archive_depth}, tree_sitter: option.None, structured_extraction: option.None, cancel_token: option.None)"
)
}
/// Render an assertion for a field that traverses a tagged-union variant.
///
/// Gleam tagged unions (sum types) require `case` pattern matching — you
/// cannot access a variant's fields via dot syntax on the union type itself.
///
/// For example, `metadata.format.excel.sheet_count` where `format` is
/// `Option(FormatMetadata)` and `FormatMetadata` has an `Excel(ExcelMetadata)`
/// variant, this emits:
///
/// ```gleam
/// case r.metadata.format {
/// option.Some(kreuzberg.Excel(e)) -> e.sheet_count |> option.unwrap(0) |> fn(n__) { n__ >= 2 } |> should.equal(True)
/// _ -> panic as "expected Excel format metadata"
/// }
/// ```
fn render_tagged_union_assertion(
out: &mut String,
assertion: &Assertion,
result_var: &str,
prefix: &str,
variant: &str,
suffix: &str,
field_resolver: &FieldResolver,
) {
// Build the accessor for the field up to (but not including) the variant.
// e.g. prefix="metadata.format" → r.metadata.format
let prefix_expr = if prefix.is_empty() {
result_var.to_string()
} else {
format!("{result_var}.{prefix}")
};
// Gleam constructor name is PascalCase of the variant.
// e.g. "excel" → "Excel", "email" → "FormatMetadataEmail" etc.
// The package module is emitted as the module qualifier.
let constructor = variant.to_pascal_case();
// module_path is "kreuzberg" — use a fixed qualifier since this is always
// the kreuzberg package's FormatMetadata type.
let module_qualifier = "kreuzberg";
// The inner variable bound to the variant payload.
let inner_var = "fmt_inner__";
// Determine whether the suffix field is optional or an array.
// The resolved full path for the suffix is `{prefix}.{variant}.{suffix}`.
let full_suffix_path = if prefix.is_empty() {
format!("{variant}.{suffix}")
} else {
format!("{prefix}.{variant}.{suffix}")
};
let suffix_is_optional = field_resolver.is_optional(&full_suffix_path);
let suffix_is_array = field_resolver.is_array(&full_suffix_path);
// Open the case block.
let _ = writeln!(out, " case {prefix_expr} {{");
let _ = writeln!(
out,
" option.Some({module_qualifier}.{constructor}({inner_var})) -> {{"
);
// Build the inner field expression.
let inner_field_expr = if suffix.is_empty() {
inner_var.to_string()
} else {
format!("{inner_var}.{suffix}")
};
// Emit the assertion body inside the Some branch.
match assertion.assertion_type.as_str() {
"equals" => {
if let Some(expected) = &assertion.value {
let gleam_val = json_to_gleam(expected);
if suffix_is_optional {
let default = default_gleam_value_for_optional(&gleam_val);
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap({default}) |> should.equal({gleam_val})"
);
} else {
let _ = writeln!(out, " {inner_field_expr} |> should.equal({gleam_val})");
}
}
}
"contains" => {
if let Some(expected) = &assertion.value {
let gleam_val = json_to_gleam(expected);
if suffix_is_array {
// List of strings: check any element contains the value.
let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
let _ = writeln!(
out,
" items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
);
} else if suffix_is_optional {
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
);
} else {
let _ = writeln!(
out,
" {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
);
}
}
}
"contains_all" => {
if let Some(values) = &assertion.values {
if suffix_is_array {
// List of strings: for each expected value, check any element contains it.
let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
for val in values {
let gleam_val = json_to_gleam(val);
let _ = writeln!(
out,
" items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
);
}
} else if suffix_is_optional {
for val in values {
let gleam_val = json_to_gleam(val);
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
);
}
} else {
for val in values {
let gleam_val = json_to_gleam(val);
let _ = writeln!(
out,
" {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
);
}
}
}
}
"greater_than_or_equal" => {
if let Some(val) = &assertion.value {
let gleam_val = json_to_gleam(val);
if suffix_is_optional {
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
);
} else {
let _ = writeln!(
out,
" {inner_field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
);
}
}
}
"greater_than" => {
if let Some(val) = &assertion.value {
let gleam_val = json_to_gleam(val);
if suffix_is_optional {
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
);
} else {
let _ = writeln!(
out,
" {inner_field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
);
}
}
}
"less_than" => {
if let Some(val) = &assertion.value {
let gleam_val = json_to_gleam(val);
if suffix_is_optional {
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
);
} else {
let _ = writeln!(
out,
" {inner_field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
);
}
}
}
"less_than_or_equal" => {
if let Some(val) = &assertion.value {
let gleam_val = json_to_gleam(val);
if suffix_is_optional {
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
);
} else {
let _ = writeln!(
out,
" {inner_field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
);
}
}
}
"count_min" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
if suffix_is_optional {
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap([]) |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
);
} else {
let _ = writeln!(
out,
" {inner_field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
);
}
}
}
}
"count_equals" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
if suffix_is_optional {
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap([]) |> list.length |> should.equal({n})"
);
} else {
let _ = writeln!(out, " {inner_field_expr} |> list.length |> should.equal({n})");
}
}
}
}
"not_empty" => {
if suffix_is_optional {
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(False)"
);
} else {
let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(False)");
}
}
"is_empty" => {
if suffix_is_optional {
let _ = writeln!(
out,
" {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(True)"
);
} else {
let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(True)");
}
}
"is_true" => {
let _ = writeln!(out, " {inner_field_expr} |> should.equal(True)");
}
"is_false" => {
let _ = writeln!(out, " {inner_field_expr} |> should.equal(False)");
}
other => {
let _ = writeln!(
out,
" // tagged-union assertion '{other}' not yet implemented for Gleam"
);
}
}
// Close the Some branch and add wildcard fallback.
let _ = writeln!(out, " }}");
let _ = writeln!(
out,
" _ -> panic as \"expected {module_qualifier}.{constructor} format metadata\""
);
let _ = writeln!(out, " }}");
}
/// Return a sensible Gleam default value for `option.unwrap(default)` based
/// on the type inferred from the JSON expected value string.
fn default_gleam_value_for_optional(gleam_val: &str) -> &'static str {
if gleam_val.starts_with('"') {
"\"\""
} else if gleam_val == "True" || gleam_val == "False" {
"False"
} else if gleam_val.contains('.') {
"0.0"
} else {
"0"
}
}
fn render_assertion(
out: &mut String,
assertion: &Assertion,
result_var: &str,
field_resolver: &FieldResolver,
enum_fields: &HashSet<String>,
result_is_array: bool,
) {
// Skip assertions on fields that don't exist on the result type.
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;
}
}
// Detect tagged-union variant access (e.g., metadata.format.excel.sheet_count).
// Gleam tagged unions are sum types — direct field access is not valid.
// Instead, emit a case expression to pattern-match the variant.
if let Some(f) = &assertion.field {
if !f.is_empty() {
if let Some((prefix, variant, suffix)) = field_resolver.tagged_union_split(f) {
render_tagged_union_assertion(out, assertion, result_var, &prefix, &variant, &suffix, field_resolver);
return;
}
}
}
// Detect field paths with an optional prefix segment (e.g. "document.nodes" where
// "document" is Option(DocumentStructure)). These require a case expression to unwrap.
if let Some(f) = &assertion.field {
if !f.is_empty() {
let parts: Vec<&str> = f.split('.').collect();
let mut opt_prefix: Option<(String, usize)> = None;
for i in 1..parts.len() {
let prefix_path = parts[..i].join(".");
if field_resolver.is_optional(&prefix_path) {
opt_prefix = Some((prefix_path, i));
break;
}
}
if let Some((optional_prefix, suffix_start)) = opt_prefix {
let prefix_expr = format!("{result_var}.{optional_prefix}");
let suffix_parts = &parts[suffix_start..];
let suffix_str = suffix_parts.join(".");
let inner_var = "opt_inner__";
let inner_expr = if suffix_str.is_empty() {
inner_var.to_string()
} else {
format!("{inner_var}.{suffix_str}")
};
let _ = writeln!(out, " case {prefix_expr} {{");
let _ = writeln!(out, " option.Some({inner_var}) -> {{");
match assertion.assertion_type.as_str() {
"count_min" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(
out,
" {inner_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
);
}
}
}
"count_equals" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(out, " {inner_expr} |> list.length |> should.equal({n})");
}
}
}
"not_empty" => {
let _ = writeln!(out, " {inner_expr} |> list.is_empty |> should.equal(False)");
}
"min_length" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(
out,
" {inner_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
);
}
}
}
other => {
let _ = writeln!(
out,
" // optional-prefix assertion '{other}' not yet implemented for Gleam"
);
}
}
let _ = writeln!(out, " }}");
let _ = writeln!(out, " option.None -> should.fail()");
let _ = writeln!(out, " }}");
return;
}
}
}
// Determine if this field is an optional type (e.g. metadata.output_format).
// For optional fields, equality comparisons must wrap in option.Some(...).
let field_is_optional = assertion
.field
.as_deref()
.is_some_and(|f| !f.is_empty() && field_resolver.is_optional(f));
// Determine if this field is an enum type.
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(),
};
// Check if the field (or root result) is an array for `contains` assertions.
// When no field is specified (root result) and call config says result_is_array, treat as array.
let field_is_array = {
let f = assertion.field.as_deref().unwrap_or("");
let is_root = f.is_empty();
(is_root && result_is_array) || field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f))
};
match assertion.assertion_type.as_str() {
"equals" => {
if let Some(expected) = &assertion.value {
let gleam_val = json_to_gleam(expected);
if field_is_optional {
// Option(T) equality — wrap in option.Some().
let _ = writeln!(out, " {field_expr} |> should.equal(option.Some({gleam_val}))");
} else {
let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
}
}
}
"contains" => {
if let Some(expected) = &assertion.value {
let gleam_val = json_to_gleam(expected);
if field_is_array {
// List(String) — check any element contains the value.
let _ = writeln!(
out,
" {field_expr} |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
);
} else if field_is_optional {
let _ = writeln!(
out,
" {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
);
} else {
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);
if field_is_optional {
let _ = writeln!(
out,
" {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
);
} else {
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" => {
if field_is_optional {
// Option(T) — check it is Some.
let _ = writeln!(out, " {field_expr} |> option.is_some |> should.equal(True)");
} else {
let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
}
}
"is_empty" => {
if field_is_optional {
let _ = writeln!(out, " {field_expr} |> option.is_none |> should.equal(True)");
} else {
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 |> fn(n__) {{ n__ >= {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 |> fn(n__) {{ n__ <= {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 |> fn(n__) {{ n__ >= {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" => {
// Already handled by the call succeeding.
}
"error" => {
// Handled at the test case level.
}
"greater_than" => {
if let Some(val) = &assertion.value {
let gleam_val = json_to_gleam(val);
let _ = writeln!(
out,
" {field_expr} |> fn(n__) {{ n__ > {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} |> fn(n__) {{ n__ < {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} |> fn(n__) {{ n__ >= {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} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
);
}
}
"contains_any" => {
if let Some(values) = &assertion.values {
let vals_list = values.iter().map(json_to_gleam).collect::<Vec<_>>().join(", ");
let _ = writeln!(
out,
" [{vals_list}] |> list.any(fn(v__) {{ string.contains({field_expr}, v__) }}) |> 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}");
}
}
}
/// Convert a `serde_json::Value` to a Gleam literal string.
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))
}
}
}