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