1use crate::config::E2eConfig;
9use crate::escape::{escape_gleam, sanitize_filename, sanitize_ident};
10use crate::field_access::FieldResolver;
11use crate::fixture::{Assertion, Fixture, FixtureGroup, ValidationErrorExpectation};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::hash::{self, CommentStyle};
15use anyhow::Result;
16use heck::{ToPascalCase, ToSnakeCase};
17use std::collections::HashSet;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22use super::client;
23
24pub struct GleamE2eCodegen;
26
27impl E2eCodegen for GleamE2eCodegen {
28 fn generate(
29 &self,
30 groups: &[FixtureGroup],
31 e2e_config: &E2eConfig,
32 config: &ResolvedCrateConfig,
33 _type_defs: &[alef_core::ir::TypeDef],
34 _enums: &[alef_core::ir::EnumDef],
35 ) -> Result<Vec<GeneratedFile>> {
36 let lang = self.language_name();
37 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
38
39 let mut files = Vec::new();
40
41 let call = &e2e_config.call;
43 let overrides = call.overrides.get(lang);
44 let module_path = overrides
45 .and_then(|o| o.module.as_ref())
46 .cloned()
47 .unwrap_or_else(|| call.module.clone());
48 let function_name = overrides
49 .and_then(|o| o.function.as_ref())
50 .cloned()
51 .unwrap_or_else(|| call.function.clone());
52 let result_var = &call.result_var;
53
54 let gleam_pkg = e2e_config.resolve_package("gleam");
56 let pkg_path = gleam_pkg
57 .as_ref()
58 .and_then(|p| p.path.as_ref())
59 .cloned()
60 .unwrap_or_else(|| "../../packages/gleam".to_string());
61 let pkg_name = gleam_pkg
62 .as_ref()
63 .and_then(|p| p.name.as_ref())
64 .cloned()
65 .unwrap_or_else(|| config.name.to_snake_case());
66
67 files.push(GeneratedFile {
69 path: output_base.join("gleam.toml"),
70 content: render_gleam_toml(&pkg_path, &pkg_name, e2e_config.dep_mode),
71 generated_header: false,
72 });
73
74 let app_name = pkg_name.clone();
79
80 let e2e_helpers = format!(
84 "// Generated by alef. Do not edit by hand.\n\
85 // E2e helper module — provides file-reading utilities for Gleam tests.\n\
86 import gleam/dynamic\n\
87 \n\
88 /// Read a file into a BitArray via the Erlang :file module.\n\
89 /// The path is relative to the e2e working directory when `gleam test` runs.\n\
90 @external(erlang, \"file\", \"read_file\")\n\
91 pub fn read_file_bytes(path: String) -> Result(BitArray, dynamic.Dynamic)\n\
92 \n\
93 /// Ensure the {app_name} OTP application and all its dependencies are started.\n\
94 /// This is required when running `gleam test` outside of `mix test`, since the\n\
95 /// Rustler NIF init hook needs the :{app_name} application to be started before\n\
96 /// any binding-native functions can be called.\n\
97 /// Calls the Erlang shim e2e_startup:start_app/0.\n\
98 @external(erlang, \"e2e_startup\", \"start_app\")\n\
99 pub fn start_app() -> Nil\n",
100 );
101 let erlang_startup = format!(
106 "%% Generated by alef. Do not edit by hand.\n\
107 %% Starts the {app_name} OTP application and all its dependencies.\n\
108 %% Called by e2e_gleam_test.main/0 before gleeunit.main/0.\n\
109 -module(e2e_startup).\n\
110 -export([start_app/0]).\n\
111 \n\
112 start_app() ->\n\
113 \x20\x20\x20\x20%% Elixir runtime must be started before {app_name} NIF init\n\
114 \x20\x20\x20\x20%% because Rustler uses Elixir.Application.app_dir/2 to locate the .so.\n\
115 \x20\x20\x20\x20%% Gracefully fall back when Elixir is not a runtime dependency.\n\
116 \x20\x20\x20\x20case application:ensure_all_started(elixir) of\n\
117 \x20\x20\x20\x20\x20\x20\x20\x20{{ok, _}} -> ok;\n\
118 \x20\x20\x20\x20\x20\x20\x20\x20{{error, _}} -> ok\n\
119 \x20\x20\x20\x20end,\n\
120 \x20\x20\x20\x20{{ok, _}} = application:ensure_all_started({app_name}),\n\
121 \x20\x20\x20\x20nil.\n",
122 );
123 files.push(GeneratedFile {
124 path: output_base.join("src").join("e2e_gleam.gleam"),
125 content: e2e_helpers,
126 generated_header: false,
127 });
128 files.push(GeneratedFile {
129 path: output_base.join("src").join("e2e_startup.erl"),
130 content: erlang_startup,
131 generated_header: false,
132 });
133
134 let mut any_tests = false;
136
137 for group in groups {
139 let active: Vec<&Fixture> = group
140 .fixtures
141 .iter()
142 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
144 .filter(|f| {
148 if let Some(http) = &f.http {
149 let has_upgrade = http
150 .request
151 .headers
152 .iter()
153 .any(|(k, v)| k.eq_ignore_ascii_case("upgrade") && v.eq_ignore_ascii_case("websocket"));
154 !has_upgrade
155 } else {
156 true
157 }
158 })
159 .collect();
162
163 if active.is_empty() {
164 continue;
165 }
166
167 let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
168 let field_resolver = FieldResolver::new(
169 &e2e_config.fields,
170 &e2e_config.fields_optional,
171 &e2e_config.result_fields,
172 &e2e_config.fields_array,
173 &e2e_config.fields_method_calls,
174 );
175 let element_constructors: &[alef_core::config::GleamElementConstructor] = config
178 .gleam
179 .as_ref()
180 .map(|g| g.element_constructors.as_slice())
181 .unwrap_or(&[]);
182 let json_object_wrapper: Option<&str> =
185 config.gleam.as_ref().and_then(|g| g.json_object_wrapper.as_deref());
186 let content = render_test_file(
187 &group.category,
188 &active,
189 e2e_config,
190 &module_path,
191 &function_name,
192 result_var,
193 &e2e_config.call.args,
194 &field_resolver,
195 &e2e_config.fields_enum,
196 element_constructors,
197 json_object_wrapper,
198 );
199 files.push(GeneratedFile {
200 path: output_base.join("test").join(filename),
201 content,
202 generated_header: true,
203 });
204 any_tests = true;
205 }
206
207 let entry = if any_tests {
212 concat!(
213 "// Generated by alef. Do not edit by hand.\n",
214 "import gleeunit\n",
215 "import e2e_gleam\n",
216 "\n",
217 "pub fn main() {\n",
218 " let _ = e2e_gleam.start_app()\n",
219 " gleeunit.main()\n",
220 "}\n",
221 )
222 .to_string()
223 } else {
224 concat!(
225 "// Generated by alef. Do not edit by hand.\n",
226 "// No fixture-driven tests for Gleam — e2e tests require HTTP fixtures\n",
227 "// or non-HTTP fixtures with gleam-specific call overrides.\n",
228 "import gleeunit\n",
229 "import gleeunit/should\n",
230 "\n",
231 "pub fn main() {\n",
232 " gleeunit.main()\n",
233 "}\n",
234 "\n",
235 "pub fn compilation_smoke_test() {\n",
236 " True |> should.equal(True)\n",
237 "}\n",
238 )
239 .to_string()
240 };
241 files.push(GeneratedFile {
242 path: output_base.join("test").join("e2e_gleam_test.gleam"),
243 content: entry,
244 generated_header: false,
245 });
246
247 Ok(files)
248 }
249
250 fn language_name(&self) -> &'static str {
251 "gleam"
252 }
253}
254
255fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
260 use alef_core::template_versions::hex;
261 let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
262 let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
263 let gleam_httpc = hex::GLEAM_HTTPC_VERSION_RANGE;
264 let envoy = hex::ENVOY_VERSION_RANGE;
265 let deps = match dep_mode {
266 crate::config::DependencyMode::Registry => {
267 format!(
268 r#"{pkg_name} = ">= 0.1.0"
269gleam_stdlib = "{stdlib}"
270gleeunit = "{gleeunit}"
271gleam_httpc = "{gleam_httpc}"
272gleam_http = ">= 4.0.0 and < 5.0.0"
273envoy = "{envoy}""#
274 )
275 }
276 crate::config::DependencyMode::Local => {
277 format!(
278 r#"{pkg_name} = {{ path = "{pkg_path}" }}
279gleam_stdlib = "{stdlib}"
280gleeunit = "{gleeunit}"
281gleam_httpc = "{gleam_httpc}"
282gleam_http = ">= 4.0.0 and < 5.0.0"
283envoy = "{envoy}""#
284 )
285 }
286 };
287
288 format!(
289 r#"name = "e2e_gleam"
290version = "0.1.0"
291target = "erlang"
292
293[dependencies]
294{deps}
295"#
296 )
297}
298
299#[allow(clippy::too_many_arguments)]
300fn render_test_file(
301 _category: &str,
302 fixtures: &[&Fixture],
303 e2e_config: &E2eConfig,
304 module_path: &str,
305 function_name: &str,
306 result_var: &str,
307 args: &[crate::config::ArgMapping],
308 field_resolver: &FieldResolver,
309 enum_fields: &HashSet<String>,
310 element_constructors: &[alef_core::config::GleamElementConstructor],
311 json_object_wrapper: Option<&str>,
312) -> String {
313 let mut out = String::new();
314 out.push_str(&hash::header(CommentStyle::DoubleSlash));
315 let _ = writeln!(out, "import gleeunit");
316 let _ = writeln!(out, "import gleeunit/should");
317
318 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
320
321 if has_http_fixtures {
323 let _ = writeln!(out, "import gleam/httpc");
324 let _ = writeln!(out, "import gleam/http");
325 let _ = writeln!(out, "import gleam/http/request");
326 let _ = writeln!(out, "import gleam/list");
327 let _ = writeln!(out, "import gleam/result");
328 let _ = writeln!(out, "import gleam/string");
329 let _ = writeln!(out, "import envoy");
330 }
331
332 let has_non_http_with_override = fixtures.iter().any(|f| !f.is_http_test());
334 if has_non_http_with_override {
335 let _ = writeln!(out, "import {module_path}");
336 let _ = writeln!(out, "import e2e_gleam");
337 let needs_envoy_for_binding = !has_http_fixtures
339 && fixtures.iter().filter(|f| !f.is_http_test()).any(|f| {
340 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
341 cc.args.iter().any(|a| a.arg_type == "mock_url")
342 });
343 if needs_envoy_for_binding {
344 let _ = writeln!(out, "import envoy");
345 }
346 }
347 let _ = writeln!(out);
348
349 let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
351
352 for fixture in fixtures {
354 if fixture.is_http_test() {
355 continue; }
357 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
359 let has_bytes_arg = call_config.args.iter().any(|a| a.arg_type == "bytes");
360 let has_optional_string_arg = call_config.args.iter().any(|a| a.arg_type == "string" && a.optional);
362 let has_json_object_arg = call_config.args.iter().any(|a| a.arg_type == "json_object");
364 let has_handle_arg = call_config.args.iter().any(|a| a.arg_type == "handle");
366 let has_client_factory = call_config
368 .overrides
369 .get("gleam")
370 .and_then(|o| o.client_factory.as_deref())
371 .is_some()
372 || e2e_config
373 .call
374 .overrides
375 .get("gleam")
376 .and_then(|o| o.client_factory.as_deref())
377 .is_some();
378 if has_bytes_arg || has_optional_string_arg || has_json_object_arg || has_handle_arg || has_client_factory {
379 needed_modules.insert("option");
380 }
381 for assertion in &fixture.assertions {
382 let needs_case_expr = assertion
385 .field
386 .as_deref()
387 .is_some_and(|f| field_resolver.tagged_union_split(f).is_some());
388 if needs_case_expr {
389 needed_modules.insert("option");
390 }
391 if let Some(f) = &assertion.field {
393 if field_resolver.is_optional(field_resolver.resolve(f)) {
394 needed_modules.insert("option");
395 }
396 }
397 match assertion.assertion_type.as_str() {
398 "contains_any" => {
399 needed_modules.insert("string");
401 needed_modules.insert("list");
402 }
403 "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" => {
404 needed_modules.insert("string");
405 if let Some(f) = &assertion.field {
407 let resolved = field_resolver.resolve(f);
408 if field_resolver.is_array(f) || field_resolver.is_array(resolved) {
409 needed_modules.insert("list");
410 }
411 } else {
412 if call_config.result_is_array
414 || call_config.result_is_vec
415 || field_resolver.is_array("")
416 || field_resolver.is_array(field_resolver.resolve(""))
417 {
418 needed_modules.insert("list");
419 }
420 }
421 }
422 "not_empty" | "is_empty" => {
423 if let Some(f) = &assertion.field {
425 let resolved = field_resolver.resolve(f);
426 let is_opt = field_resolver.is_optional(resolved);
427 let is_arr = field_resolver.is_array(f) || field_resolver.is_array(resolved);
428 if is_arr {
429 needed_modules.insert("list");
430 } else if is_opt {
431 needed_modules.insert("option");
432 } else {
433 needed_modules.insert("string");
434 }
435 } else {
436 needed_modules.insert("list");
437 }
438 }
439 "count_min" | "count_equals" => {
440 needed_modules.insert("list");
441 }
443 "min_length" | "max_length" => {
444 needed_modules.insert("string");
445 }
447 "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
448 }
450 _ => {}
451 }
452 if needs_case_expr {
454 if let Some(f) = &assertion.field {
455 let resolved = field_resolver.resolve(f);
456 if field_resolver.is_array(resolved) {
457 needed_modules.insert("list");
458 }
459 }
460 }
461 if let Some(f) = &assertion.field {
463 if f.split('.').any(|seg| seg == "length") {
464 needed_modules.insert("list");
465 }
466 }
467 if let Some(f) = &assertion.field {
470 if !f.is_empty() {
471 let parts: Vec<&str> = f.split('.').collect();
472 let has_opt_prefix = (1..parts.len()).any(|i| {
473 let prefix_path = parts[..i].join(".");
474 field_resolver.is_optional(&prefix_path)
475 });
476 if has_opt_prefix {
477 needed_modules.insert("option");
478 if matches!(assertion.assertion_type.as_str(), "not_empty" | "is_empty") {
481 let resolved = field_resolver.resolve(f);
482 if field_resolver.is_array(f) || field_resolver.is_array(resolved) {
483 needed_modules.insert("list");
484 } else {
485 needed_modules.insert("string");
486 }
487 }
488 }
489 }
490 }
491 }
492 }
493
494 for module in &needed_modules {
496 let _ = writeln!(out, "import gleam/{module}");
497 }
498
499 if !needed_modules.is_empty() {
500 let _ = writeln!(out);
501 }
502
503 for fixture in fixtures {
505 if fixture.is_http_test() {
506 render_http_test_case(&mut out, fixture);
507 } else {
508 render_test_case(
509 &mut out,
510 fixture,
511 e2e_config,
512 module_path,
513 function_name,
514 result_var,
515 args,
516 field_resolver,
517 enum_fields,
518 element_constructors,
519 json_object_wrapper,
520 );
521 }
522 let _ = writeln!(out);
523 }
524
525 out
526}
527
528struct GleamTestClientRenderer;
533
534impl client::TestClientRenderer for GleamTestClientRenderer {
535 fn language_name(&self) -> &'static str {
536 "gleam"
537 }
538
539 fn sanitize_test_name(&self, id: &str) -> String {
544 let raw = sanitize_ident(id);
545 let stripped = raw.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
546 if stripped.is_empty() { raw } else { stripped.to_string() }
547 }
548
549 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
555 let _ = writeln!(out, "// {description}");
556 let _ = writeln!(out, "pub fn {fn_name}_test() {{");
557 if let Some(reason) = skip_reason {
558 let escaped = escape_gleam(reason);
561 let _ = writeln!(out, " // skipped: {escaped}");
562 let _ = writeln!(out, " Nil");
563 }
564 }
565
566 fn render_test_close(&self, out: &mut String) {
568 let _ = writeln!(out, "}}");
569 }
570
571 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
577 let path = ctx.path;
578
579 let _ = writeln!(out, " let base_url = case envoy.get(\"MOCK_SERVER_URL\") {{");
581 let _ = writeln!(out, " Ok(u) -> u");
582 let _ = writeln!(out, " Error(_) -> \"http://localhost:8080\"");
583 let _ = writeln!(out, " }}");
584
585 let _ = writeln!(out, " let assert Ok(req) = request.to(base_url <> \"{path}\")");
587
588 let method_const = match ctx.method.to_uppercase().as_str() {
590 "GET" => "Get",
591 "POST" => "Post",
592 "PUT" => "Put",
593 "DELETE" => "Delete",
594 "PATCH" => "Patch",
595 "HEAD" => "Head",
596 "OPTIONS" => "Options",
597 _ => "Post",
598 };
599 let _ = writeln!(out, " let req = request.set_method(req, http.{method_const})");
600
601 if ctx.body.is_some() {
603 let content_type = ctx.content_type.unwrap_or("application/json");
604 let escaped_ct = escape_gleam(content_type);
605 let _ = writeln!(
606 out,
607 " let req = request.set_header(req, \"content-type\", \"{escaped_ct}\")"
608 );
609 }
610
611 for (name, value) in ctx.headers {
613 let lower = name.to_lowercase();
614 if matches!(lower.as_str(), "content-length" | "host" | "transfer-encoding") {
615 continue;
616 }
617 let escaped_name = escape_gleam(name);
618 let escaped_value = escape_gleam(value);
619 let _ = writeln!(
620 out,
621 " let req = request.set_header(req, \"{escaped_name}\", \"{escaped_value}\")"
622 );
623 }
624
625 if !ctx.cookies.is_empty() {
627 let cookie_str: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
628 let escaped_cookie = escape_gleam(&cookie_str.join("; "));
629 let _ = writeln!(
630 out,
631 " let req = request.set_header(req, \"cookie\", \"{escaped_cookie}\")"
632 );
633 }
634
635 if let Some(body) = ctx.body {
637 let json_str = serde_json::to_string(body).unwrap_or_default();
638 let escaped = escape_gleam(&json_str);
639 let _ = writeln!(out, " let req = request.set_body(req, \"{escaped}\")");
640 }
641
642 let resp = ctx.response_var;
644 let _ = writeln!(out, " let assert Ok({resp}) = httpc.send(req)");
645 }
646
647 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
649 let _ = writeln!(out, " {response_var}.status |> should.equal({status})");
650 }
651
652 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
658 let escaped_name = escape_gleam(&name.to_lowercase());
659 match expected {
660 "<<absent>>" => {
661 let _ = writeln!(
662 out,
663 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_false()"
664 );
665 }
666 "<<present>>" | "<<uuid>>" => {
667 let _ = writeln!(
669 out,
670 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_true()"
671 );
672 }
673 literal => {
674 let _escaped_value = escape_gleam(literal);
677 let _ = writeln!(
678 out,
679 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_true()"
680 );
681 }
682 }
683 }
684
685 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
691 let escaped = match expected {
692 serde_json::Value::String(s) => escape_gleam(s),
693 other => escape_gleam(&serde_json::to_string(other).unwrap_or_default()),
694 };
695 let _ = writeln!(
696 out,
697 " {response_var}.body |> string.trim |> should.equal(\"{escaped}\")"
698 );
699 }
700
701 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
707 if let Some(obj) = expected.as_object() {
708 for (key, val) in obj {
709 let fragment = escape_gleam(&format!("\"{}\":", key));
710 let _ = writeln!(
711 out,
712 " {response_var}.body |> string.contains(\"{fragment}\") |> should.equal(True)"
713 );
714 let _ = val; }
716 }
717 }
718
719 fn render_assert_validation_errors(
725 &self,
726 out: &mut String,
727 response_var: &str,
728 errors: &[ValidationErrorExpectation],
729 ) {
730 for err in errors {
731 let escaped_msg = escape_gleam(&err.msg);
732 let _ = writeln!(
733 out,
734 " {response_var}.body |> string.contains(\"{escaped_msg}\") |> should.equal(True)"
735 );
736 }
737 }
738}
739
740fn render_http_test_case(out: &mut String, fixture: &Fixture) {
746 client::http_call::render_http_test(out, &GleamTestClientRenderer, fixture);
747}
748
749#[allow(clippy::too_many_arguments)]
750fn render_test_case(
751 out: &mut String,
752 fixture: &Fixture,
753 e2e_config: &E2eConfig,
754 module_path: &str,
755 _function_name: &str,
756 _result_var: &str,
757 _args: &[crate::config::ArgMapping],
758 field_resolver: &FieldResolver,
759 enum_fields: &HashSet<String>,
760 element_constructors: &[alef_core::config::GleamElementConstructor],
761 json_object_wrapper: Option<&str>,
762) {
763 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
765 let lang = "gleam";
766 let call_overrides = call_config.overrides.get(lang);
767 let function_name = call_overrides
768 .and_then(|o| o.function.as_ref())
769 .cloned()
770 .unwrap_or_else(|| call_config.function.clone());
771 let client_factory: Option<String> = call_overrides
773 .and_then(|o| o.client_factory.as_deref())
774 .or_else(|| {
775 e2e_config
776 .call
777 .overrides
778 .get(lang)
779 .and_then(|o| o.client_factory.as_deref())
780 })
781 .map(|s| s.to_string());
782 let client_factory_trailing_args: Vec<String> = call_overrides
786 .map(|o| o.client_factory_trailing_args.clone())
787 .filter(|v| !v.is_empty())
788 .unwrap_or_else(|| {
789 e2e_config
790 .call
791 .overrides
792 .get(lang)
793 .map(|o| o.client_factory_trailing_args.clone())
794 .unwrap_or_default()
795 });
796 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
799 let result_var = &call_config.result_var;
800 let args = &call_config.args;
801
802 let raw_name = sanitize_ident(&fixture.id);
807 let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
808 let test_name = if stripped.is_empty() {
809 raw_name.as_str()
810 } else {
811 stripped
812 };
813 let description = &fixture.description;
814 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
815
816 let options_type: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
817 let options_via: &str = call_overrides
818 .and_then(|o| o.options_via.as_deref())
819 .unwrap_or("default");
820
821 let test_documents_path = e2e_config.test_documents_relative_from(0);
822 let build_result = build_args_and_setup(
823 &fixture.input,
824 args,
825 &fixture.id,
826 &test_documents_path,
827 element_constructors,
828 json_object_wrapper,
829 module_path,
830 &extra_args,
831 options_type,
832 options_via,
833 );
834
835 let _ = writeln!(out, "// {description}");
838 let _ = writeln!(out, "pub fn {test_name}_test() {{");
839
840 let Some((setup_lines, args_str)) = build_result else {
844 let _ = writeln!(
845 out,
846 " // skipped: json_object arg requires typed record construction not yet supported in Gleam e2e"
847 );
848 let _ = writeln!(out, " Nil");
849 let _ = writeln!(out, "}}");
850 return;
851 };
852
853 for line in &setup_lines {
854 let _ = writeln!(out, " {line}");
855 }
856
857 let call_prefix = if let Some(ref factory) = client_factory {
860 use heck::ToSnakeCase;
861 let factory_snake = factory.to_snake_case();
862 let trailing = if client_factory_trailing_args.is_empty() {
866 String::new()
867 } else {
868 format!(", {}", client_factory_trailing_args.join(", "))
869 };
870 let base_url_expr = args
872 .iter()
873 .find(|a| a.arg_type == "mock_url")
874 .map(|_a| {
875 format!("let base_url__ = case envoy.get(\"MOCK_SERVER_URL\") {{ Ok(u) -> u Error(_) -> \"http://localhost:8080\" }}\n let assert Ok(client) = {module_path}.{factory_snake}(\"test-key\", option.Some(base_url__){trailing})\n let _ = client")
880 })
881 .unwrap_or_else(|| {
882 format!("let assert Ok(client) = {module_path}.{factory_snake}(\"test-key\", option.None{trailing})\n let _ = client")
883 });
884 for l in base_url_expr.lines() {
886 let _ = writeln!(out, " {l}");
887 }
888 let full_args = if args_str.is_empty() {
890 "client".to_string()
891 } else {
892 format!("client, {args_str}")
893 };
894 if expects_error {
895 let _ = writeln!(out, " {module_path}.{function_name}({full_args}) |> should.be_error()");
896 let _ = writeln!(out, "}}");
897 return;
898 }
899 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({full_args})");
900 None } else {
902 if expects_error {
903 let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
904 let _ = writeln!(out, "}}");
905 return;
906 }
907 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
908 Some(()) };
910 let _ = call_prefix; let _ = writeln!(out, " {result_var} |> should.be_ok()");
912 let _ = writeln!(out, " let assert Ok(r) = {result_var}");
913
914 let result_is_array = call_config.result_is_array || call_config.result_is_vec;
915 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple;
919 let pkg_module = e2e_config
924 .resolve_package("gleam")
925 .as_ref()
926 .and_then(|p| p.name.as_ref())
927 .cloned()
928 .unwrap_or_else(|| module_path.split('.').next().unwrap_or(module_path).to_string());
929
930 let mut effective_enum_fields: HashSet<String> = enum_fields.clone();
935 if let Some(o) = call_overrides {
936 for k in o.enum_fields.keys() {
937 effective_enum_fields.insert(k.clone());
938 }
939 for k in o.assert_enum_fields.keys() {
940 effective_enum_fields.insert(k.clone());
941 }
942 }
943
944 for assertion in &fixture.assertions {
945 if result_is_simple {
948 if let Some(f) = &assertion.field {
949 if !f.is_empty() {
950 let _ = writeln!(out, " // skipped: field '{f}' not accessible on simple result type");
951 continue;
952 }
953 }
954 }
955 render_assertion(
956 out,
957 assertion,
958 "r",
959 field_resolver,
960 &effective_enum_fields,
961 result_is_array,
962 &pkg_module,
963 );
964 }
965
966 let _ = writeln!(out, "}}");
967}
968
969#[allow(clippy::too_many_arguments)]
987fn build_args_and_setup(
988 input: &serde_json::Value,
989 args: &[crate::config::ArgMapping],
990 fixture_id: &str,
991 test_documents_path: &str,
992 element_constructors: &[alef_core::config::GleamElementConstructor],
993 json_object_wrapper: Option<&str>,
994 module_path: &str,
995 extra_args: &[String],
996 options_type: Option<&str>,
997 options_via: &str,
998) -> Option<(Vec<String>, String)> {
999 if args.is_empty() && extra_args.is_empty() {
1000 return Some((Vec::new(), String::new()));
1001 }
1002
1003 for arg in args {
1006 if arg.arg_type == "json_object" {
1007 let element_type = arg.element_type.as_deref().unwrap_or("");
1008 let has_recipe =
1009 !element_type.is_empty() && element_constructors.iter().any(|r| r.element_type == element_type);
1010 let has_wrapper = json_object_wrapper.is_some();
1011 let has_from_json = options_via == "from_json" && options_type.is_some();
1012 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1014 let val = input.get(field);
1015 let is_null_optional = arg.optional && matches!(val, None | Some(serde_json::Value::Null));
1016 if !has_recipe && !has_wrapper && !has_from_json && !is_null_optional {
1017 return None;
1018 }
1019 }
1020 }
1021
1022 let mut setup_lines: Vec<String> = Vec::new();
1023 let mut parts: Vec<String> = Vec::new();
1024 let mut bytes_var_counter = 0usize;
1025
1026 for arg in args {
1027 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1028 let val = input.get(field);
1029
1030 match arg.arg_type.as_str() {
1031 "handle" => {
1032 let name = &arg.name;
1036 let constructor = format!("create_{}", name.to_snake_case());
1037 setup_lines.push(format!(
1038 "let assert Ok({name}) = {module_path}.{constructor}(option.None)"
1039 ));
1040 parts.push(name.clone());
1041 continue;
1042 }
1043 "mock_url" => {
1044 let name = &arg.name;
1046 setup_lines.push(format!(
1047 "let {name} = case envoy.get(\"MOCK_SERVER_URL\") {{ Ok(base) -> base <> \"/fixtures/{fixture_id}\" Error(_) -> \"http://localhost:8080/fixtures/{fixture_id}\" }}"
1048 ));
1049 parts.push(name.clone());
1050 continue;
1051 }
1052 "file_path" => {
1053 let path = val.and_then(|v| v.as_str()).unwrap_or("");
1057 let full_path = format!("{test_documents_path}/{path}");
1058 parts.push(format!("\"{}\"", escape_gleam(&full_path)));
1059 }
1060 "bytes" => {
1061 let path = val.and_then(|v| v.as_str()).unwrap_or("");
1065 let var_name = if bytes_var_counter == 0 {
1066 "data_bytes__".to_string()
1067 } else {
1068 format!("data_bytes_{bytes_var_counter}__")
1069 };
1070 bytes_var_counter += 1;
1071 let full_path = format!("{test_documents_path}/{path}");
1073 setup_lines.push(format!(
1074 "let assert Ok({var_name}) = e2e_gleam.read_file_bytes(\"{}\")",
1075 escape_gleam(&full_path)
1076 ));
1077 parts.push(var_name);
1078 }
1079 "string" if arg.optional => {
1080 match val {
1082 None | Some(serde_json::Value::Null) => {
1083 parts.push("option.None".to_string());
1084 }
1085 Some(serde_json::Value::String(s)) if s.is_empty() => {
1086 parts.push("option.None".to_string());
1087 }
1088 Some(serde_json::Value::String(s)) => {
1089 parts.push(format!("option.Some(\"{}\")", escape_gleam(s)));
1090 }
1091 Some(v) => {
1092 parts.push(format!("option.Some({})", json_to_gleam(v)));
1093 }
1094 }
1095 }
1096 "string" => {
1097 match val {
1099 None | Some(serde_json::Value::Null) => {
1100 parts.push("\"\"".to_string());
1101 }
1102 Some(serde_json::Value::String(s)) => {
1103 parts.push(format!("\"{}\"", escape_gleam(s)));
1104 }
1105 Some(v) => {
1106 parts.push(json_to_gleam(v));
1107 }
1108 }
1109 }
1110 "json_object" => {
1111 if options_via == "from_json" {
1113 if let Some(opts_type) = options_type {
1114 let empty_obj = serde_json::Value::Object(Default::default());
1115 let config_val = val.unwrap_or(&empty_obj);
1116 if !config_val.is_null() {
1117 use heck::ToSnakeCase;
1118 let snake_opts = opts_type.to_snake_case();
1119 let json_str = serde_json::to_string(config_val).unwrap_or_default();
1120 let escaped = escape_gleam(&json_str);
1121 let var_name = format!("{}_json__", &arg.name);
1122 setup_lines.push(format!(
1123 "let assert Ok({var_name}) = {module_path}.{snake_opts}_from_json(\"{escaped}\")"
1124 ));
1125 parts.push(var_name);
1126 }
1127 continue;
1128 }
1129 }
1130
1131 let element_type = arg.element_type.as_deref().unwrap_or("");
1136 let recipe = if element_type.is_empty() {
1137 None
1138 } else {
1139 element_constructors.iter().find(|r| r.element_type == element_type)
1140 };
1141
1142 if let Some(recipe) = recipe {
1143 let items_expr = match val {
1147 Some(serde_json::Value::Array(arr)) => {
1148 let items: Vec<String> = arr
1149 .iter()
1150 .map(|item| render_gleam_element_constructor(item, recipe, test_documents_path))
1151 .collect();
1152 format!("[{}]", items.join(", "))
1153 }
1154 _ => "[]".to_string(),
1155 };
1156 if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
1157 parts.push("[]".to_string());
1158 } else {
1159 parts.push(items_expr);
1160 }
1161 } else if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
1162 parts.push("option.None".to_string());
1163 } else {
1164 let empty_obj = serde_json::Value::Object(Default::default());
1165 let config_val = val.unwrap_or(&empty_obj);
1166 let json_literal = json_to_gleam(config_val);
1167 let emitted = match json_object_wrapper {
1172 Some(template) => template.replace("{json}", &json_literal),
1173 None => json_literal,
1174 };
1175 parts.push(emitted);
1176 }
1177 }
1178 "int" | "integer" => match val {
1179 None | Some(serde_json::Value::Null) if arg.optional => {}
1180 None | Some(serde_json::Value::Null) => parts.push("0".to_string()),
1181 Some(v) => parts.push(json_to_gleam(v)),
1182 },
1183 "bool" | "boolean" => match val {
1184 Some(serde_json::Value::Bool(true)) => parts.push("True".to_string()),
1185 Some(serde_json::Value::Bool(false)) | None | Some(serde_json::Value::Null) => {
1186 if !arg.optional {
1187 parts.push("False".to_string());
1188 }
1189 }
1190 Some(v) => parts.push(json_to_gleam(v)),
1191 },
1192 _ => {
1193 match val {
1195 None | Some(serde_json::Value::Null) if arg.optional => {}
1196 None | Some(serde_json::Value::Null) => parts.push("Nil".to_string()),
1197 Some(v) => parts.push(json_to_gleam(v)),
1198 }
1199 }
1200 }
1201 }
1202
1203 for extra in extra_args {
1206 parts.push(extra.clone());
1207 }
1208
1209 Some((setup_lines, parts.join(", ")))
1210}
1211
1212fn render_gleam_element_constructor(
1224 item: &serde_json::Value,
1225 recipe: &alef_core::config::GleamElementConstructor,
1226 test_documents_path: &str,
1227) -> String {
1228 let mut field_exprs: Vec<String> = Vec::with_capacity(recipe.fields.len());
1229 for field in &recipe.fields {
1230 let expr = match field.kind.as_str() {
1231 "file_path" => {
1232 let json_field = field.json_field.as_deref().unwrap_or("");
1233 let path = item.get(json_field).and_then(|v| v.as_str()).unwrap_or("");
1234 let full = if path.starts_with('/') {
1235 path.to_string()
1236 } else {
1237 format!("{test_documents_path}/{path}")
1238 };
1239 format!("\"{}\"", escape_gleam(&full))
1240 }
1241 "byte_array" => {
1242 let json_field = field.json_field.as_deref().unwrap_or("");
1243 let bytes: Vec<String> = item
1244 .get(json_field)
1245 .and_then(|v| v.as_array())
1246 .map(|arr| arr.iter().map(|b| b.as_u64().unwrap_or(0).to_string()).collect())
1247 .unwrap_or_default();
1248 if bytes.is_empty() {
1249 "<<>>".to_string()
1250 } else {
1251 format!("<<{}>>", bytes.join(", "))
1252 }
1253 }
1254 "string" => {
1255 let json_field = field.json_field.as_deref().unwrap_or("");
1256 let value = item
1257 .get(json_field)
1258 .and_then(|v| v.as_str())
1259 .map(str::to_string)
1260 .or_else(|| field.default.clone())
1261 .unwrap_or_default();
1262 format!("\"{}\"", escape_gleam(&value))
1263 }
1264 "literal" => field.value.clone().unwrap_or_default(),
1265 other => {
1266 field
1271 .value
1272 .clone()
1273 .unwrap_or_else(|| format!("\"<unsupported kind: {other}>\""))
1274 }
1275 };
1276 field_exprs.push(format!("{}: {}", field.gleam_field, expr));
1277 }
1278 format!("{}({})", recipe.constructor, field_exprs.join(", "))
1279}
1280
1281#[allow(clippy::too_many_arguments)]
1290fn render_tagged_union_assertion(
1291 out: &mut String,
1292 assertion: &Assertion,
1293 result_var: &str,
1294 prefix: &str,
1295 variant: &str,
1296 suffix: &str,
1297 field_resolver: &FieldResolver,
1298 pkg_module: &str,
1299) {
1300 let prefix_expr = if prefix.is_empty() {
1303 result_var.to_string()
1304 } else {
1305 format!("{result_var}.{prefix}")
1306 };
1307
1308 let constructor = variant.to_pascal_case();
1312 let module_qualifier = pkg_module;
1316
1317 let inner_var = "fmt_inner__";
1319
1320 let full_suffix_path = if prefix.is_empty() {
1323 format!("{variant}.{suffix}")
1324 } else {
1325 format!("{prefix}.{variant}.{suffix}")
1326 };
1327 let suffix_is_optional = field_resolver.is_optional(&full_suffix_path);
1328 let suffix_is_array = field_resolver.is_array(&full_suffix_path);
1329
1330 let _ = writeln!(out, " case {prefix_expr} {{");
1332 let _ = writeln!(
1333 out,
1334 " option.Some({module_qualifier}.{constructor}({inner_var})) -> {{"
1335 );
1336
1337 let inner_field_expr = if suffix.is_empty() {
1339 inner_var.to_string()
1340 } else {
1341 format!("{inner_var}.{suffix}")
1342 };
1343
1344 match assertion.assertion_type.as_str() {
1346 "equals" => {
1347 if let Some(expected) = &assertion.value {
1348 let gleam_val = json_to_gleam(expected);
1349 if suffix_is_optional {
1350 let default = default_gleam_value_for_optional(&gleam_val);
1351 let _ = writeln!(
1352 out,
1353 " {inner_field_expr} |> option.unwrap({default}) |> should.equal({gleam_val})"
1354 );
1355 } else {
1356 let _ = writeln!(out, " {inner_field_expr} |> should.equal({gleam_val})");
1357 }
1358 }
1359 }
1360 "contains" => {
1361 if let Some(expected) = &assertion.value {
1362 let gleam_val = json_to_gleam(expected);
1363 if suffix_is_array {
1364 let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
1366 let _ = writeln!(
1367 out,
1368 " items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1369 );
1370 } else if suffix_is_optional {
1371 let _ = writeln!(
1372 out,
1373 " {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1374 );
1375 } else {
1376 let _ = writeln!(
1377 out,
1378 " {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1379 );
1380 }
1381 }
1382 }
1383 "contains_all" => {
1384 if let Some(values) = &assertion.values {
1385 if suffix_is_array {
1386 let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
1388 for val in values {
1389 let gleam_val = json_to_gleam(val);
1390 let _ = writeln!(
1391 out,
1392 " items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1393 );
1394 }
1395 } else if suffix_is_optional {
1396 for val in values {
1397 let gleam_val = json_to_gleam(val);
1398 let _ = writeln!(
1399 out,
1400 " {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1401 );
1402 }
1403 } else {
1404 for val in values {
1405 let gleam_val = json_to_gleam(val);
1406 let _ = writeln!(
1407 out,
1408 " {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1409 );
1410 }
1411 }
1412 }
1413 }
1414 "greater_than_or_equal" => {
1415 if let Some(val) = &assertion.value {
1416 let gleam_val = json_to_gleam(val);
1417 if suffix_is_optional {
1418 let _ = writeln!(
1419 out,
1420 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1421 );
1422 } else {
1423 let _ = writeln!(
1424 out,
1425 " {inner_field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1426 );
1427 }
1428 }
1429 }
1430 "greater_than" => {
1431 if let Some(val) = &assertion.value {
1432 let gleam_val = json_to_gleam(val);
1433 if suffix_is_optional {
1434 let _ = writeln!(
1435 out,
1436 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1437 );
1438 } else {
1439 let _ = writeln!(
1440 out,
1441 " {inner_field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1442 );
1443 }
1444 }
1445 }
1446 "less_than" => {
1447 if let Some(val) = &assertion.value {
1448 let gleam_val = json_to_gleam(val);
1449 if suffix_is_optional {
1450 let _ = writeln!(
1451 out,
1452 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1453 );
1454 } else {
1455 let _ = writeln!(
1456 out,
1457 " {inner_field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1458 );
1459 }
1460 }
1461 }
1462 "less_than_or_equal" => {
1463 if let Some(val) = &assertion.value {
1464 let gleam_val = json_to_gleam(val);
1465 if suffix_is_optional {
1466 let _ = writeln!(
1467 out,
1468 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1469 );
1470 } else {
1471 let _ = writeln!(
1472 out,
1473 " {inner_field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1474 );
1475 }
1476 }
1477 }
1478 "count_min" => {
1479 if let Some(val) = &assertion.value {
1480 if let Some(n) = val.as_u64() {
1481 if suffix_is_optional {
1482 let _ = writeln!(
1483 out,
1484 " {inner_field_expr} |> option.unwrap([]) |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1485 );
1486 } else {
1487 let _ = writeln!(
1488 out,
1489 " {inner_field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1490 );
1491 }
1492 }
1493 }
1494 }
1495 "count_equals" => {
1496 if let Some(val) = &assertion.value {
1497 if let Some(n) = val.as_u64() {
1498 if suffix_is_optional {
1499 let _ = writeln!(
1500 out,
1501 " {inner_field_expr} |> option.unwrap([]) |> list.length |> should.equal({n})"
1502 );
1503 } else {
1504 let _ = writeln!(out, " {inner_field_expr} |> list.length |> should.equal({n})");
1505 }
1506 }
1507 }
1508 }
1509 "not_empty" => {
1510 if suffix_is_optional {
1511 let _ = writeln!(
1512 out,
1513 " {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(False)"
1514 );
1515 } else if suffix_is_array {
1516 let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(False)");
1517 } else {
1518 let _ = writeln!(
1519 out,
1520 " {inner_field_expr} |> string.is_empty |> should.equal(False)"
1521 );
1522 }
1523 }
1524 "is_empty" => {
1525 if suffix_is_optional {
1526 let _ = writeln!(
1527 out,
1528 " {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(True)"
1529 );
1530 } else if suffix_is_array {
1531 let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(True)");
1532 } else {
1533 let _ = writeln!(out, " {inner_field_expr} |> string.is_empty |> should.equal(True)");
1534 }
1535 }
1536 "is_true" => {
1537 let _ = writeln!(out, " {inner_field_expr} |> should.equal(True)");
1538 }
1539 "is_false" => {
1540 let _ = writeln!(out, " {inner_field_expr} |> should.equal(False)");
1541 }
1542 other => {
1543 let _ = writeln!(
1544 out,
1545 " // tagged-union assertion '{other}' not yet implemented for Gleam"
1546 );
1547 }
1548 }
1549
1550 let _ = writeln!(out, " }}");
1552 let _ = writeln!(
1553 out,
1554 " _ -> panic as \"expected {module_qualifier}.{constructor} format metadata\""
1555 );
1556 let _ = writeln!(out, " }}");
1557}
1558
1559fn default_gleam_value_for_optional(gleam_val: &str) -> &'static str {
1562 if gleam_val.starts_with('"') {
1563 "\"\""
1564 } else if gleam_val == "True" || gleam_val == "False" {
1565 "False"
1566 } else if gleam_val.contains('.') {
1567 "0.0"
1568 } else {
1569 "0"
1570 }
1571}
1572
1573fn render_assertion(
1574 out: &mut String,
1575 assertion: &Assertion,
1576 result_var: &str,
1577 field_resolver: &FieldResolver,
1578 enum_fields: &HashSet<String>,
1579 result_is_array: bool,
1580 pkg_module: &str,
1581) {
1582 if let Some(f) = &assertion.field {
1584 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1585 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1586 return;
1587 }
1588 }
1589
1590 if let Some(f) = &assertion.field {
1593 let has_index = f.contains("[].") || {
1594 let mut chars = f.chars().peekable();
1596 let mut found = false;
1597 while let Some(c) = chars.next() {
1598 if c == '[' {
1599 let mut has_digits = false;
1601 while chars.peek().map(|d| d.is_ascii_digit()).unwrap_or(false) {
1602 chars.next();
1603 has_digits = true;
1604 }
1605 if has_digits && chars.next() == Some(']') && chars.peek() == Some(&'.') {
1606 found = true;
1607 break;
1608 }
1609 }
1610 }
1611 found
1612 };
1613 if has_index {
1614 let _ = writeln!(
1615 out,
1616 " // skipped: array element field '{f}' not yet supported in Gleam e2e"
1617 );
1618 return;
1619 }
1620 }
1621
1622 if let Some(f) = &assertion.field {
1626 if !f.is_empty() {
1627 if let Some((prefix, variant, suffix)) = field_resolver.tagged_union_split(f) {
1628 render_tagged_union_assertion(
1629 out,
1630 assertion,
1631 result_var,
1632 &prefix,
1633 &variant,
1634 &suffix,
1635 field_resolver,
1636 pkg_module,
1637 );
1638 return;
1639 }
1640 }
1641 }
1642
1643 if let Some(f) = &assertion.field {
1646 if !f.is_empty() {
1647 let parts: Vec<&str> = f.split('.').collect();
1648 let mut opt_prefix: Option<(String, usize)> = None;
1649 for i in 1..parts.len() {
1650 let prefix_path = parts[..i].join(".");
1651 if field_resolver.is_optional(&prefix_path) {
1652 opt_prefix = Some((prefix_path, i));
1653 break;
1654 }
1655 }
1656 if let Some((optional_prefix, suffix_start)) = opt_prefix {
1657 let prefix_expr = format!("{result_var}.{optional_prefix}");
1658 let suffix_parts = &parts[suffix_start..];
1659 let suffix_str = suffix_parts.join(".");
1660 let inner_var = "opt_inner__";
1661 let inner_expr = if suffix_str.is_empty() {
1662 inner_var.to_string()
1663 } else {
1664 format!("{inner_var}.{suffix_str}")
1665 };
1666 let _ = writeln!(out, " case {prefix_expr} {{");
1667 let _ = writeln!(out, " option.Some({inner_var}) -> {{");
1668 match assertion.assertion_type.as_str() {
1669 "count_min" => {
1670 if let Some(val) = &assertion.value {
1671 if let Some(n) = val.as_u64() {
1672 let _ = writeln!(
1673 out,
1674 " {inner_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1675 );
1676 }
1677 }
1678 }
1679 "count_equals" => {
1680 if let Some(val) = &assertion.value {
1681 if let Some(n) = val.as_u64() {
1682 let _ = writeln!(out, " {inner_expr} |> list.length |> should.equal({n})");
1683 }
1684 }
1685 }
1686 "not_empty" => {
1687 let is_arr = field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f));
1688 if is_arr {
1689 let _ = writeln!(out, " {inner_expr} |> list.is_empty |> should.equal(False)");
1690 } else {
1691 let _ = writeln!(out, " {inner_expr} |> string.is_empty |> should.equal(False)");
1692 }
1693 }
1694 "min_length" => {
1695 if let Some(val) = &assertion.value {
1696 if let Some(n) = val.as_u64() {
1697 let _ = writeln!(
1698 out,
1699 " {inner_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1700 );
1701 }
1702 }
1703 }
1704 other => {
1705 let _ = writeln!(
1706 out,
1707 " // optional-prefix assertion '{other}' not yet implemented for Gleam"
1708 );
1709 }
1710 }
1711 let _ = writeln!(out, " }}");
1712 let _ = writeln!(out, " option.None -> should.fail()");
1713 let _ = writeln!(out, " }}");
1714 return;
1715 }
1716 }
1717 }
1718
1719 let field_is_optional = assertion
1722 .field
1723 .as_deref()
1724 .is_some_and(|f| !f.is_empty() && field_resolver.is_optional(field_resolver.resolve(f)));
1725
1726 let field_is_enum = assertion
1731 .field
1732 .as_deref()
1733 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1734 if field_is_enum && assertion.assertion_type == "equals" {
1735 let f = assertion.field.as_deref().unwrap_or("");
1736 let _ = writeln!(
1737 out,
1738 " // skipped: enum field '{f}' comparison not yet supported in Gleam e2e"
1739 );
1740 return;
1741 }
1742
1743 let field_expr = match &assertion.field {
1744 Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
1745 _ => result_var.to_string(),
1746 };
1747
1748 let field_is_array = {
1751 let f = assertion.field.as_deref().unwrap_or("");
1752 let is_root = f.is_empty();
1753 (is_root && result_is_array) || field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f))
1754 };
1755
1756 match assertion.assertion_type.as_str() {
1757 "equals" => {
1758 if let Some(expected) = &assertion.value {
1759 let gleam_val = json_to_gleam(expected);
1760 if field_is_optional {
1761 let _ = writeln!(out, " {field_expr} |> should.equal(option.Some({gleam_val}))");
1763 } else {
1764 let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
1765 }
1766 }
1767 }
1768 "contains" => {
1769 if let Some(expected) = &assertion.value {
1770 let gleam_val = json_to_gleam(expected);
1771 if field_is_array {
1772 let _ = writeln!(
1774 out,
1775 " {field_expr} |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1776 );
1777 } else if field_is_optional {
1778 let _ = writeln!(
1779 out,
1780 " {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1781 );
1782 } else {
1783 let _ = writeln!(
1784 out,
1785 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1786 );
1787 }
1788 }
1789 }
1790 "contains_all" => {
1791 if let Some(values) = &assertion.values {
1792 for val in values {
1793 let gleam_val = json_to_gleam(val);
1794 if field_is_optional {
1795 let _ = writeln!(
1796 out,
1797 " {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1798 );
1799 } else {
1800 let _ = writeln!(
1801 out,
1802 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1803 );
1804 }
1805 }
1806 }
1807 }
1808 "not_contains" => {
1809 if let Some(expected) = &assertion.value {
1810 let gleam_val = json_to_gleam(expected);
1811 let _ = writeln!(
1812 out,
1813 " {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
1814 );
1815 }
1816 }
1817 "not_empty" => {
1818 if field_is_optional {
1819 let _ = writeln!(out, " {field_expr} |> option.is_some |> should.equal(True)");
1821 } else if field_is_array {
1822 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
1823 } else {
1824 let _ = writeln!(out, " {field_expr} |> string.is_empty |> should.equal(False)");
1825 }
1826 }
1827 "is_empty" => {
1828 if field_is_optional {
1829 let _ = writeln!(out, " {field_expr} |> option.is_none |> should.equal(True)");
1830 } else if field_is_array {
1831 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
1832 } else {
1833 let _ = writeln!(out, " {field_expr} |> string.is_empty |> should.equal(True)");
1834 }
1835 }
1836 "starts_with" => {
1837 if let Some(expected) = &assertion.value {
1838 let gleam_val = json_to_gleam(expected);
1839 let _ = writeln!(
1840 out,
1841 " {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
1842 );
1843 }
1844 }
1845 "ends_with" => {
1846 if let Some(expected) = &assertion.value {
1847 let gleam_val = json_to_gleam(expected);
1848 let _ = writeln!(
1849 out,
1850 " {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
1851 );
1852 }
1853 }
1854 "min_length" => {
1855 if let Some(val) = &assertion.value {
1856 if let Some(n) = val.as_u64() {
1857 let _ = writeln!(
1858 out,
1859 " {field_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1860 );
1861 }
1862 }
1863 }
1864 "max_length" => {
1865 if let Some(val) = &assertion.value {
1866 if let Some(n) = val.as_u64() {
1867 let _ = writeln!(
1868 out,
1869 " {field_expr} |> string.length |> fn(n__) {{ n__ <= {n} }} |> should.equal(True)"
1870 );
1871 }
1872 }
1873 }
1874 "count_min" => {
1875 if let Some(val) = &assertion.value {
1876 if let Some(n) = val.as_u64() {
1877 let _ = writeln!(
1878 out,
1879 " {field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1880 );
1881 }
1882 }
1883 }
1884 "count_equals" => {
1885 if let Some(val) = &assertion.value {
1886 if let Some(n) = val.as_u64() {
1887 let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
1888 }
1889 }
1890 }
1891 "is_true" => {
1892 let _ = writeln!(out, " {field_expr} |> should.equal(True)");
1893 }
1894 "is_false" => {
1895 let _ = writeln!(out, " {field_expr} |> should.equal(False)");
1896 }
1897 "not_error" => {
1898 }
1900 "error" => {
1901 }
1903 "greater_than" => {
1904 if let Some(val) = &assertion.value {
1905 let gleam_val = json_to_gleam(val);
1906 let _ = writeln!(
1907 out,
1908 " {field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1909 );
1910 }
1911 }
1912 "less_than" => {
1913 if let Some(val) = &assertion.value {
1914 let gleam_val = json_to_gleam(val);
1915 let _ = writeln!(
1916 out,
1917 " {field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1918 );
1919 }
1920 }
1921 "greater_than_or_equal" => {
1922 if let Some(val) = &assertion.value {
1923 let gleam_val = json_to_gleam(val);
1924 let _ = writeln!(
1925 out,
1926 " {field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1927 );
1928 }
1929 }
1930 "less_than_or_equal" => {
1931 if let Some(val) = &assertion.value {
1932 let gleam_val = json_to_gleam(val);
1933 let _ = writeln!(
1934 out,
1935 " {field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1936 );
1937 }
1938 }
1939 "contains_any" => {
1940 if let Some(values) = &assertion.values {
1941 let vals_list = values.iter().map(json_to_gleam).collect::<Vec<_>>().join(", ");
1942 let _ = writeln!(
1943 out,
1944 " [{vals_list}] |> list.any(fn(v__) {{ string.contains({field_expr}, v__) }}) |> should.equal(True)"
1945 );
1946 }
1947 }
1948 "matches_regex" => {
1949 let _ = writeln!(out, " // regex match not yet implemented for Gleam");
1950 }
1951 "method_result" => {
1952 let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
1953 }
1954 other => {
1955 panic!("Gleam e2e generator: unsupported assertion type: {other}");
1956 }
1957 }
1958}
1959
1960fn json_to_gleam(value: &serde_json::Value) -> String {
1962 match value {
1963 serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
1964 serde_json::Value::Bool(b) => {
1965 if *b {
1966 "True".to_string()
1967 } else {
1968 "False".to_string()
1969 }
1970 }
1971 serde_json::Value::Number(n) => n.to_string(),
1972 serde_json::Value::Null => "Nil".to_string(),
1973 serde_json::Value::Array(arr) => {
1974 let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
1975 format!("[{}]", items.join(", "))
1976 }
1977 serde_json::Value::Object(_) => {
1978 let json_str = serde_json::to_string(value).unwrap_or_default();
1979 format!("\"{}\"", escape_gleam(&json_str))
1980 }
1981 }
1982}
1983
1984#[cfg(test)]
1985mod tests {
1986 use super::*;
1987 use alef_core::config::{GleamElementConstructor, GleamElementField};
1988
1989 fn batch_file_item_recipe() -> GleamElementConstructor {
1990 GleamElementConstructor {
1991 element_type: "BatchFileItem".to_string(),
1992 constructor: "kreuzberg.BatchFileItem".to_string(),
1993 fields: vec![
1994 GleamElementField {
1995 gleam_field: "path".to_string(),
1996 kind: "file_path".to_string(),
1997 json_field: Some("path".to_string()),
1998 default: None,
1999 value: None,
2000 },
2001 GleamElementField {
2002 gleam_field: "config".to_string(),
2003 kind: "literal".to_string(),
2004 json_field: None,
2005 default: None,
2006 value: Some("option.None".to_string()),
2007 },
2008 ],
2009 }
2010 }
2011
2012 #[test]
2013 fn render_element_constructor_file_path_relative_path_gets_test_documents_prefix() {
2014 let item = serde_json::json!({ "path": "docx/fake.docx" });
2015 let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
2016 assert_eq!(
2017 out,
2018 "kreuzberg.BatchFileItem(path: \"../../test_documents/docx/fake.docx\", config: option.None)"
2019 );
2020 }
2021
2022 #[test]
2023 fn render_element_constructor_file_path_absolute_path_passes_through() {
2024 let item = serde_json::json!({ "path": "/etc/some/absolute" });
2025 let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
2026 assert!(
2027 out.contains("\"/etc/some/absolute\""),
2028 "absolute paths must NOT receive the test_documents prefix; got:\n{out}"
2029 );
2030 }
2031
2032 #[test]
2033 fn render_element_constructor_byte_array_emits_bitarray() {
2034 let recipe = GleamElementConstructor {
2035 element_type: "BatchBytesItem".to_string(),
2036 constructor: "kreuzberg.BatchBytesItem".to_string(),
2037 fields: vec![
2038 GleamElementField {
2039 gleam_field: "content".to_string(),
2040 kind: "byte_array".to_string(),
2041 json_field: Some("content".to_string()),
2042 default: None,
2043 value: None,
2044 },
2045 GleamElementField {
2046 gleam_field: "mime_type".to_string(),
2047 kind: "string".to_string(),
2048 json_field: Some("mime_type".to_string()),
2049 default: Some("text/plain".to_string()),
2050 value: None,
2051 },
2052 GleamElementField {
2053 gleam_field: "config".to_string(),
2054 kind: "literal".to_string(),
2055 json_field: None,
2056 default: None,
2057 value: Some("option.None".to_string()),
2058 },
2059 ],
2060 };
2061 let item = serde_json::json!({ "content": [72, 105], "mime_type": "text/html" });
2062 let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
2063 assert_eq!(
2064 out,
2065 "kreuzberg.BatchBytesItem(content: <<72, 105>>, mime_type: \"text/html\", config: option.None)"
2066 );
2067 }
2068
2069 #[test]
2070 fn build_args_with_json_object_wrapper_substitutes_placeholder() {
2071 use crate::config::ArgMapping;
2072 let arg = ArgMapping {
2073 name: "config".to_string(),
2074 field: "config".to_string(),
2075 arg_type: "json_object".to_string(),
2076 optional: false,
2077 owned: false,
2078 element_type: None,
2079 go_type: None,
2080 };
2081 let input = serde_json::json!({
2082 "config": { "use_cache": true, "force_ocr": false }
2083 });
2084 let Some((_setup, args_str)) = build_args_and_setup(
2085 &input,
2086 &[arg],
2087 "test_fixture",
2088 "../../test_documents",
2089 &[],
2090 Some("k.config_from_json_string({json})"),
2091 "kreuzberg",
2092 &[],
2093 None,
2094 "default",
2095 ) else {
2096 panic!("expected Some result from build_args_and_setup");
2097 };
2098 assert!(
2101 args_str.starts_with("k.config_from_json_string("),
2102 "wrapper must envelop the JSON literal; got:\n{args_str}"
2103 );
2104 assert!(
2105 args_str.contains("use_cache"),
2106 "JSON payload must reach the wrapper; got:\n{args_str}"
2107 );
2108 }
2109
2110 #[test]
2111 fn build_args_without_json_object_wrapper_returns_none_for_skip() {
2112 use crate::config::ArgMapping;
2113 let arg = ArgMapping {
2114 name: "config".to_string(),
2115 field: "config".to_string(),
2116 arg_type: "json_object".to_string(),
2117 optional: false,
2118 owned: false,
2119 element_type: None,
2120 go_type: None,
2121 };
2122 let input = serde_json::json!({ "config": { "x": 1 } });
2123 let result = build_args_and_setup(
2125 &input,
2126 &[arg],
2127 "test_fixture",
2128 "../../test_documents",
2129 &[],
2130 None,
2131 "kreuzberg",
2132 &[],
2133 None,
2134 "default",
2135 );
2136 assert!(
2137 result.is_none(),
2138 "json_object without recipe/wrapper/from_json must return None for skip; got: {result:?}"
2139 );
2140 }
2141
2142 #[test]
2143 fn render_element_constructor_string_falls_back_to_default() {
2144 let recipe = GleamElementConstructor {
2145 element_type: "BatchBytesItem".to_string(),
2146 constructor: "k.BatchBytesItem".to_string(),
2147 fields: vec![GleamElementField {
2148 gleam_field: "mime_type".to_string(),
2149 kind: "string".to_string(),
2150 json_field: Some("mime_type".to_string()),
2151 default: Some("text/plain".to_string()),
2152 value: None,
2153 }],
2154 };
2155 let item = serde_json::json!({});
2156 let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
2157 assert!(
2158 out.contains("mime_type: \"text/plain\""),
2159 "missing string field must fall back to default; got:\n{out}"
2160 );
2161 }
2162}