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 test_documents_path = e2e_config.test_documents_relative_from(0);
816 let build_result = build_args_and_setup(
817 &fixture.input,
818 args,
819 &fixture.id,
820 &test_documents_path,
821 element_constructors,
822 json_object_wrapper,
823 module_path,
824 &extra_args,
825 );
826
827 let _ = writeln!(out, "// {description}");
830 let _ = writeln!(out, "pub fn {test_name}_test() {{");
831
832 let Some((setup_lines, args_str)) = build_result else {
836 let _ = writeln!(
837 out,
838 " // skipped: json_object arg requires typed record construction not yet supported in Gleam e2e"
839 );
840 let _ = writeln!(out, " Nil");
841 let _ = writeln!(out, "}}");
842 return;
843 };
844
845 for line in &setup_lines {
846 let _ = writeln!(out, " {line}");
847 }
848
849 let call_prefix = if let Some(ref factory) = client_factory {
852 use heck::ToSnakeCase;
853 let factory_snake = factory.to_snake_case();
854 let trailing = if client_factory_trailing_args.is_empty() {
858 String::new()
859 } else {
860 format!(", {}", client_factory_trailing_args.join(", "))
861 };
862 let base_url_expr = args
864 .iter()
865 .find(|a| a.arg_type == "mock_url")
866 .map(|_a| {
867 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")
872 })
873 .unwrap_or_else(|| {
874 format!("let assert Ok(client) = {module_path}.{factory_snake}(\"test-key\", option.None{trailing})\n let _ = client")
875 });
876 for l in base_url_expr.lines() {
878 let _ = writeln!(out, " {l}");
879 }
880 let full_args = if args_str.is_empty() {
882 "client".to_string()
883 } else {
884 format!("client, {args_str}")
885 };
886 if expects_error {
887 let _ = writeln!(out, " {module_path}.{function_name}({full_args}) |> should.be_error()");
888 let _ = writeln!(out, "}}");
889 return;
890 }
891 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({full_args})");
892 None } else {
894 if expects_error {
895 let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
896 let _ = writeln!(out, "}}");
897 return;
898 }
899 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
900 Some(()) };
902 let _ = call_prefix; let _ = writeln!(out, " {result_var} |> should.be_ok()");
904 let _ = writeln!(out, " let assert Ok(r) = {result_var}");
905
906 let result_is_array = call_config.result_is_array || call_config.result_is_vec;
907 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple)
911 || call_config.result_is_simple;
912 let pkg_module = e2e_config
917 .resolve_package("gleam")
918 .as_ref()
919 .and_then(|p| p.name.as_ref())
920 .cloned()
921 .unwrap_or_else(|| module_path.split('.').next().unwrap_or(module_path).to_string());
922
923 let mut effective_enum_fields: HashSet<String> = enum_fields.clone();
928 if let Some(o) = call_overrides {
929 for k in o.enum_fields.keys() {
930 effective_enum_fields.insert(k.clone());
931 }
932 for k in o.assert_enum_fields.keys() {
933 effective_enum_fields.insert(k.clone());
934 }
935 }
936
937 for assertion in &fixture.assertions {
938 if result_is_simple {
941 if let Some(f) = &assertion.field {
942 if !f.is_empty() {
943 let _ = writeln!(
944 out,
945 " // skipped: field '{f}' not accessible on simple result type"
946 );
947 continue;
948 }
949 }
950 }
951 render_assertion(
952 out,
953 assertion,
954 "r",
955 field_resolver,
956 &effective_enum_fields,
957 result_is_array,
958 &pkg_module,
959 );
960 }
961
962 let _ = writeln!(out, "}}");
963}
964
965#[allow(clippy::too_many_arguments)]
982fn build_args_and_setup(
983 input: &serde_json::Value,
984 args: &[crate::config::ArgMapping],
985 fixture_id: &str,
986 test_documents_path: &str,
987 element_constructors: &[alef_core::config::GleamElementConstructor],
988 json_object_wrapper: Option<&str>,
989 module_path: &str,
990 extra_args: &[String],
991) -> Option<(Vec<String>, String)> {
992 if args.is_empty() && extra_args.is_empty() {
993 return Some((Vec::new(), String::new()));
994 }
995
996 for arg in args {
1000 if arg.arg_type == "json_object" {
1001 let element_type = arg.element_type.as_deref().unwrap_or("");
1002 let has_recipe =
1003 !element_type.is_empty() && element_constructors.iter().any(|r| r.element_type == element_type);
1004 let has_wrapper = json_object_wrapper.is_some();
1005 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1007 let val = input.get(field);
1008 let is_null_optional = arg.optional && matches!(val, None | Some(serde_json::Value::Null));
1009 if !has_recipe && !has_wrapper && !is_null_optional {
1010 return None;
1011 }
1012 }
1013 }
1014
1015 let mut setup_lines: Vec<String> = Vec::new();
1016 let mut parts: Vec<String> = Vec::new();
1017 let mut bytes_var_counter = 0usize;
1018
1019 for arg in args {
1020 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1021 let val = input.get(field);
1022
1023 match arg.arg_type.as_str() {
1024 "handle" => {
1025 let name = &arg.name;
1029 let constructor = format!("create_{}", name.to_snake_case());
1030 setup_lines.push(format!(
1031 "let assert Ok({name}) = {module_path}.{constructor}(option.None)"
1032 ));
1033 parts.push(name.clone());
1034 continue;
1035 }
1036 "mock_url" => {
1037 let name = &arg.name;
1039 setup_lines.push(format!(
1040 "let {name} = case envoy.get(\"MOCK_SERVER_URL\") {{ Ok(base) -> base <> \"/fixtures/{fixture_id}\" Error(_) -> \"http://localhost:8080/fixtures/{fixture_id}\" }}"
1041 ));
1042 parts.push(name.clone());
1043 continue;
1044 }
1045 "file_path" => {
1046 let path = val.and_then(|v| v.as_str()).unwrap_or("");
1050 let full_path = format!("{test_documents_path}/{path}");
1051 parts.push(format!("\"{}\"", escape_gleam(&full_path)));
1052 }
1053 "bytes" => {
1054 let path = val.and_then(|v| v.as_str()).unwrap_or("");
1058 let var_name = if bytes_var_counter == 0 {
1059 "data_bytes__".to_string()
1060 } else {
1061 format!("data_bytes_{bytes_var_counter}__")
1062 };
1063 bytes_var_counter += 1;
1064 let full_path = format!("{test_documents_path}/{path}");
1066 setup_lines.push(format!(
1067 "let assert Ok({var_name}) = e2e_gleam.read_file_bytes(\"{}\")",
1068 escape_gleam(&full_path)
1069 ));
1070 parts.push(var_name);
1071 }
1072 "string" if arg.optional => {
1073 match val {
1075 None | Some(serde_json::Value::Null) => {
1076 parts.push("option.None".to_string());
1077 }
1078 Some(serde_json::Value::String(s)) if s.is_empty() => {
1079 parts.push("option.None".to_string());
1080 }
1081 Some(serde_json::Value::String(s)) => {
1082 parts.push(format!("option.Some(\"{}\")", escape_gleam(s)));
1083 }
1084 Some(v) => {
1085 parts.push(format!("option.Some({})", json_to_gleam(v)));
1086 }
1087 }
1088 }
1089 "string" => {
1090 match val {
1092 None | Some(serde_json::Value::Null) => {
1093 parts.push("\"\"".to_string());
1094 }
1095 Some(serde_json::Value::String(s)) => {
1096 parts.push(format!("\"{}\"", escape_gleam(s)));
1097 }
1098 Some(v) => {
1099 parts.push(json_to_gleam(v));
1100 }
1101 }
1102 }
1103 "json_object" => {
1104 let element_type = arg.element_type.as_deref().unwrap_or("");
1109 let recipe = if element_type.is_empty() {
1110 None
1111 } else {
1112 element_constructors.iter().find(|r| r.element_type == element_type)
1113 };
1114
1115 if let Some(recipe) = recipe {
1116 let items_expr = match val {
1120 Some(serde_json::Value::Array(arr)) => {
1121 let items: Vec<String> = arr
1122 .iter()
1123 .map(|item| render_gleam_element_constructor(item, recipe, test_documents_path))
1124 .collect();
1125 format!("[{}]", items.join(", "))
1126 }
1127 _ => "[]".to_string(),
1128 };
1129 if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
1130 parts.push("[]".to_string());
1131 } else {
1132 parts.push(items_expr);
1133 }
1134 } else if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
1135 parts.push("option.None".to_string());
1136 } else {
1137 let empty_obj = serde_json::Value::Object(Default::default());
1138 let config_val = val.unwrap_or(&empty_obj);
1139 let json_literal = json_to_gleam(config_val);
1140 let emitted = match json_object_wrapper {
1145 Some(template) => template.replace("{json}", &json_literal),
1146 None => json_literal,
1147 };
1148 parts.push(emitted);
1149 }
1150 }
1151 "int" | "integer" => match val {
1152 None | Some(serde_json::Value::Null) if arg.optional => {}
1153 None | Some(serde_json::Value::Null) => parts.push("0".to_string()),
1154 Some(v) => parts.push(json_to_gleam(v)),
1155 },
1156 "bool" | "boolean" => match val {
1157 Some(serde_json::Value::Bool(true)) => parts.push("True".to_string()),
1158 Some(serde_json::Value::Bool(false)) | None | Some(serde_json::Value::Null) => {
1159 if !arg.optional {
1160 parts.push("False".to_string());
1161 }
1162 }
1163 Some(v) => parts.push(json_to_gleam(v)),
1164 },
1165 _ => {
1166 match val {
1168 None | Some(serde_json::Value::Null) if arg.optional => {}
1169 None | Some(serde_json::Value::Null) => parts.push("Nil".to_string()),
1170 Some(v) => parts.push(json_to_gleam(v)),
1171 }
1172 }
1173 }
1174 }
1175
1176 for extra in extra_args {
1179 parts.push(extra.clone());
1180 }
1181
1182 Some((setup_lines, parts.join(", ")))
1183}
1184
1185fn render_gleam_element_constructor(
1197 item: &serde_json::Value,
1198 recipe: &alef_core::config::GleamElementConstructor,
1199 test_documents_path: &str,
1200) -> String {
1201 let mut field_exprs: Vec<String> = Vec::with_capacity(recipe.fields.len());
1202 for field in &recipe.fields {
1203 let expr = match field.kind.as_str() {
1204 "file_path" => {
1205 let json_field = field.json_field.as_deref().unwrap_or("");
1206 let path = item.get(json_field).and_then(|v| v.as_str()).unwrap_or("");
1207 let full = if path.starts_with('/') {
1208 path.to_string()
1209 } else {
1210 format!("{test_documents_path}/{path}")
1211 };
1212 format!("\"{}\"", escape_gleam(&full))
1213 }
1214 "byte_array" => {
1215 let json_field = field.json_field.as_deref().unwrap_or("");
1216 let bytes: Vec<String> = item
1217 .get(json_field)
1218 .and_then(|v| v.as_array())
1219 .map(|arr| arr.iter().map(|b| b.as_u64().unwrap_or(0).to_string()).collect())
1220 .unwrap_or_default();
1221 if bytes.is_empty() {
1222 "<<>>".to_string()
1223 } else {
1224 format!("<<{}>>", bytes.join(", "))
1225 }
1226 }
1227 "string" => {
1228 let json_field = field.json_field.as_deref().unwrap_or("");
1229 let value = item
1230 .get(json_field)
1231 .and_then(|v| v.as_str())
1232 .map(str::to_string)
1233 .or_else(|| field.default.clone())
1234 .unwrap_or_default();
1235 format!("\"{}\"", escape_gleam(&value))
1236 }
1237 "literal" => field.value.clone().unwrap_or_default(),
1238 other => {
1239 field
1244 .value
1245 .clone()
1246 .unwrap_or_else(|| format!("\"<unsupported kind: {other}>\""))
1247 }
1248 };
1249 field_exprs.push(format!("{}: {}", field.gleam_field, expr));
1250 }
1251 format!("{}({})", recipe.constructor, field_exprs.join(", "))
1252}
1253
1254#[allow(clippy::too_many_arguments)]
1263fn render_tagged_union_assertion(
1264 out: &mut String,
1265 assertion: &Assertion,
1266 result_var: &str,
1267 prefix: &str,
1268 variant: &str,
1269 suffix: &str,
1270 field_resolver: &FieldResolver,
1271 pkg_module: &str,
1272) {
1273 let prefix_expr = if prefix.is_empty() {
1276 result_var.to_string()
1277 } else {
1278 format!("{result_var}.{prefix}")
1279 };
1280
1281 let constructor = variant.to_pascal_case();
1285 let module_qualifier = pkg_module;
1289
1290 let inner_var = "fmt_inner__";
1292
1293 let full_suffix_path = if prefix.is_empty() {
1296 format!("{variant}.{suffix}")
1297 } else {
1298 format!("{prefix}.{variant}.{suffix}")
1299 };
1300 let suffix_is_optional = field_resolver.is_optional(&full_suffix_path);
1301 let suffix_is_array = field_resolver.is_array(&full_suffix_path);
1302
1303 let _ = writeln!(out, " case {prefix_expr} {{");
1305 let _ = writeln!(
1306 out,
1307 " option.Some({module_qualifier}.{constructor}({inner_var})) -> {{"
1308 );
1309
1310 let inner_field_expr = if suffix.is_empty() {
1312 inner_var.to_string()
1313 } else {
1314 format!("{inner_var}.{suffix}")
1315 };
1316
1317 match assertion.assertion_type.as_str() {
1319 "equals" => {
1320 if let Some(expected) = &assertion.value {
1321 let gleam_val = json_to_gleam(expected);
1322 if suffix_is_optional {
1323 let default = default_gleam_value_for_optional(&gleam_val);
1324 let _ = writeln!(
1325 out,
1326 " {inner_field_expr} |> option.unwrap({default}) |> should.equal({gleam_val})"
1327 );
1328 } else {
1329 let _ = writeln!(out, " {inner_field_expr} |> should.equal({gleam_val})");
1330 }
1331 }
1332 }
1333 "contains" => {
1334 if let Some(expected) = &assertion.value {
1335 let gleam_val = json_to_gleam(expected);
1336 if suffix_is_array {
1337 let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
1339 let _ = writeln!(
1340 out,
1341 " items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1342 );
1343 } else if suffix_is_optional {
1344 let _ = writeln!(
1345 out,
1346 " {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1347 );
1348 } else {
1349 let _ = writeln!(
1350 out,
1351 " {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1352 );
1353 }
1354 }
1355 }
1356 "contains_all" => {
1357 if let Some(values) = &assertion.values {
1358 if suffix_is_array {
1359 let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
1361 for val in values {
1362 let gleam_val = json_to_gleam(val);
1363 let _ = writeln!(
1364 out,
1365 " items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1366 );
1367 }
1368 } else if suffix_is_optional {
1369 for val in values {
1370 let gleam_val = json_to_gleam(val);
1371 let _ = writeln!(
1372 out,
1373 " {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1374 );
1375 }
1376 } else {
1377 for val in values {
1378 let gleam_val = json_to_gleam(val);
1379 let _ = writeln!(
1380 out,
1381 " {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1382 );
1383 }
1384 }
1385 }
1386 }
1387 "greater_than_or_equal" => {
1388 if let Some(val) = &assertion.value {
1389 let gleam_val = json_to_gleam(val);
1390 if suffix_is_optional {
1391 let _ = writeln!(
1392 out,
1393 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1394 );
1395 } else {
1396 let _ = writeln!(
1397 out,
1398 " {inner_field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1399 );
1400 }
1401 }
1402 }
1403 "greater_than" => {
1404 if let Some(val) = &assertion.value {
1405 let gleam_val = json_to_gleam(val);
1406 if suffix_is_optional {
1407 let _ = writeln!(
1408 out,
1409 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1410 );
1411 } else {
1412 let _ = writeln!(
1413 out,
1414 " {inner_field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1415 );
1416 }
1417 }
1418 }
1419 "less_than" => {
1420 if let Some(val) = &assertion.value {
1421 let gleam_val = json_to_gleam(val);
1422 if suffix_is_optional {
1423 let _ = writeln!(
1424 out,
1425 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1426 );
1427 } else {
1428 let _ = writeln!(
1429 out,
1430 " {inner_field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1431 );
1432 }
1433 }
1434 }
1435 "less_than_or_equal" => {
1436 if let Some(val) = &assertion.value {
1437 let gleam_val = json_to_gleam(val);
1438 if suffix_is_optional {
1439 let _ = writeln!(
1440 out,
1441 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1442 );
1443 } else {
1444 let _ = writeln!(
1445 out,
1446 " {inner_field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1447 );
1448 }
1449 }
1450 }
1451 "count_min" => {
1452 if let Some(val) = &assertion.value {
1453 if let Some(n) = val.as_u64() {
1454 if suffix_is_optional {
1455 let _ = writeln!(
1456 out,
1457 " {inner_field_expr} |> option.unwrap([]) |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1458 );
1459 } else {
1460 let _ = writeln!(
1461 out,
1462 " {inner_field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1463 );
1464 }
1465 }
1466 }
1467 }
1468 "count_equals" => {
1469 if let Some(val) = &assertion.value {
1470 if let Some(n) = val.as_u64() {
1471 if suffix_is_optional {
1472 let _ = writeln!(
1473 out,
1474 " {inner_field_expr} |> option.unwrap([]) |> list.length |> should.equal({n})"
1475 );
1476 } else {
1477 let _ = writeln!(out, " {inner_field_expr} |> list.length |> should.equal({n})");
1478 }
1479 }
1480 }
1481 }
1482 "not_empty" => {
1483 if suffix_is_optional {
1484 let _ = writeln!(
1485 out,
1486 " {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(False)"
1487 );
1488 } else if suffix_is_array {
1489 let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(False)");
1490 } else {
1491 let _ = writeln!(
1492 out,
1493 " {inner_field_expr} |> string.is_empty |> should.equal(False)"
1494 );
1495 }
1496 }
1497 "is_empty" => {
1498 if suffix_is_optional {
1499 let _ = writeln!(
1500 out,
1501 " {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(True)"
1502 );
1503 } else if suffix_is_array {
1504 let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(True)");
1505 } else {
1506 let _ = writeln!(out, " {inner_field_expr} |> string.is_empty |> should.equal(True)");
1507 }
1508 }
1509 "is_true" => {
1510 let _ = writeln!(out, " {inner_field_expr} |> should.equal(True)");
1511 }
1512 "is_false" => {
1513 let _ = writeln!(out, " {inner_field_expr} |> should.equal(False)");
1514 }
1515 other => {
1516 let _ = writeln!(
1517 out,
1518 " // tagged-union assertion '{other}' not yet implemented for Gleam"
1519 );
1520 }
1521 }
1522
1523 let _ = writeln!(out, " }}");
1525 let _ = writeln!(
1526 out,
1527 " _ -> panic as \"expected {module_qualifier}.{constructor} format metadata\""
1528 );
1529 let _ = writeln!(out, " }}");
1530}
1531
1532fn default_gleam_value_for_optional(gleam_val: &str) -> &'static str {
1535 if gleam_val.starts_with('"') {
1536 "\"\""
1537 } else if gleam_val == "True" || gleam_val == "False" {
1538 "False"
1539 } else if gleam_val.contains('.') {
1540 "0.0"
1541 } else {
1542 "0"
1543 }
1544}
1545
1546fn render_assertion(
1547 out: &mut String,
1548 assertion: &Assertion,
1549 result_var: &str,
1550 field_resolver: &FieldResolver,
1551 enum_fields: &HashSet<String>,
1552 result_is_array: bool,
1553 pkg_module: &str,
1554) {
1555 if let Some(f) = &assertion.field {
1557 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1558 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1559 return;
1560 }
1561 }
1562
1563 if let Some(f) = &assertion.field {
1566 let has_index = f.contains("[].") || {
1567 let mut chars = f.chars().peekable();
1569 let mut found = false;
1570 while let Some(c) = chars.next() {
1571 if c == '[' {
1572 let mut has_digits = false;
1574 while chars.peek().map(|d| d.is_ascii_digit()).unwrap_or(false) {
1575 chars.next();
1576 has_digits = true;
1577 }
1578 if has_digits && chars.next() == Some(']') && chars.peek() == Some(&'.') {
1579 found = true;
1580 break;
1581 }
1582 }
1583 }
1584 found
1585 };
1586 if has_index {
1587 let _ = writeln!(
1588 out,
1589 " // skipped: array element field '{f}' not yet supported in Gleam e2e"
1590 );
1591 return;
1592 }
1593 }
1594
1595 if let Some(f) = &assertion.field {
1599 if !f.is_empty() {
1600 if let Some((prefix, variant, suffix)) = field_resolver.tagged_union_split(f) {
1601 render_tagged_union_assertion(
1602 out,
1603 assertion,
1604 result_var,
1605 &prefix,
1606 &variant,
1607 &suffix,
1608 field_resolver,
1609 pkg_module,
1610 );
1611 return;
1612 }
1613 }
1614 }
1615
1616 if let Some(f) = &assertion.field {
1619 if !f.is_empty() {
1620 let parts: Vec<&str> = f.split('.').collect();
1621 let mut opt_prefix: Option<(String, usize)> = None;
1622 for i in 1..parts.len() {
1623 let prefix_path = parts[..i].join(".");
1624 if field_resolver.is_optional(&prefix_path) {
1625 opt_prefix = Some((prefix_path, i));
1626 break;
1627 }
1628 }
1629 if let Some((optional_prefix, suffix_start)) = opt_prefix {
1630 let prefix_expr = format!("{result_var}.{optional_prefix}");
1631 let suffix_parts = &parts[suffix_start..];
1632 let suffix_str = suffix_parts.join(".");
1633 let inner_var = "opt_inner__";
1634 let inner_expr = if suffix_str.is_empty() {
1635 inner_var.to_string()
1636 } else {
1637 format!("{inner_var}.{suffix_str}")
1638 };
1639 let _ = writeln!(out, " case {prefix_expr} {{");
1640 let _ = writeln!(out, " option.Some({inner_var}) -> {{");
1641 match assertion.assertion_type.as_str() {
1642 "count_min" => {
1643 if let Some(val) = &assertion.value {
1644 if let Some(n) = val.as_u64() {
1645 let _ = writeln!(
1646 out,
1647 " {inner_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1648 );
1649 }
1650 }
1651 }
1652 "count_equals" => {
1653 if let Some(val) = &assertion.value {
1654 if let Some(n) = val.as_u64() {
1655 let _ = writeln!(out, " {inner_expr} |> list.length |> should.equal({n})");
1656 }
1657 }
1658 }
1659 "not_empty" => {
1660 let is_arr = field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f));
1661 if is_arr {
1662 let _ = writeln!(out, " {inner_expr} |> list.is_empty |> should.equal(False)");
1663 } else {
1664 let _ = writeln!(out, " {inner_expr} |> string.is_empty |> should.equal(False)");
1665 }
1666 }
1667 "min_length" => {
1668 if let Some(val) = &assertion.value {
1669 if let Some(n) = val.as_u64() {
1670 let _ = writeln!(
1671 out,
1672 " {inner_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1673 );
1674 }
1675 }
1676 }
1677 other => {
1678 let _ = writeln!(
1679 out,
1680 " // optional-prefix assertion '{other}' not yet implemented for Gleam"
1681 );
1682 }
1683 }
1684 let _ = writeln!(out, " }}");
1685 let _ = writeln!(out, " option.None -> should.fail()");
1686 let _ = writeln!(out, " }}");
1687 return;
1688 }
1689 }
1690 }
1691
1692 let field_is_optional = assertion
1695 .field
1696 .as_deref()
1697 .is_some_and(|f| !f.is_empty() && field_resolver.is_optional(field_resolver.resolve(f)));
1698
1699 let field_is_enum = assertion
1704 .field
1705 .as_deref()
1706 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1707 if field_is_enum && assertion.assertion_type == "equals" {
1708 let f = assertion.field.as_deref().unwrap_or("");
1709 let _ = writeln!(
1710 out,
1711 " // skipped: enum field '{f}' comparison not yet supported in Gleam e2e"
1712 );
1713 return;
1714 }
1715
1716 let field_expr = match &assertion.field {
1717 Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
1718 _ => result_var.to_string(),
1719 };
1720
1721 let field_is_array = {
1724 let f = assertion.field.as_deref().unwrap_or("");
1725 let is_root = f.is_empty();
1726 (is_root && result_is_array) || field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f))
1727 };
1728
1729 match assertion.assertion_type.as_str() {
1730 "equals" => {
1731 if let Some(expected) = &assertion.value {
1732 let gleam_val = json_to_gleam(expected);
1733 if field_is_optional {
1734 let _ = writeln!(out, " {field_expr} |> should.equal(option.Some({gleam_val}))");
1736 } else {
1737 let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
1738 }
1739 }
1740 }
1741 "contains" => {
1742 if let Some(expected) = &assertion.value {
1743 let gleam_val = json_to_gleam(expected);
1744 if field_is_array {
1745 let _ = writeln!(
1747 out,
1748 " {field_expr} |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1749 );
1750 } else if field_is_optional {
1751 let _ = writeln!(
1752 out,
1753 " {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1754 );
1755 } else {
1756 let _ = writeln!(
1757 out,
1758 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1759 );
1760 }
1761 }
1762 }
1763 "contains_all" => {
1764 if let Some(values) = &assertion.values {
1765 for val in values {
1766 let gleam_val = json_to_gleam(val);
1767 if field_is_optional {
1768 let _ = writeln!(
1769 out,
1770 " {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1771 );
1772 } else {
1773 let _ = writeln!(
1774 out,
1775 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1776 );
1777 }
1778 }
1779 }
1780 }
1781 "not_contains" => {
1782 if let Some(expected) = &assertion.value {
1783 let gleam_val = json_to_gleam(expected);
1784 let _ = writeln!(
1785 out,
1786 " {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
1787 );
1788 }
1789 }
1790 "not_empty" => {
1791 if field_is_optional {
1792 let _ = writeln!(out, " {field_expr} |> option.is_some |> should.equal(True)");
1794 } else if field_is_array {
1795 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
1796 } else {
1797 let _ = writeln!(out, " {field_expr} |> string.is_empty |> should.equal(False)");
1798 }
1799 }
1800 "is_empty" => {
1801 if field_is_optional {
1802 let _ = writeln!(out, " {field_expr} |> option.is_none |> should.equal(True)");
1803 } else if field_is_array {
1804 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
1805 } else {
1806 let _ = writeln!(out, " {field_expr} |> string.is_empty |> should.equal(True)");
1807 }
1808 }
1809 "starts_with" => {
1810 if let Some(expected) = &assertion.value {
1811 let gleam_val = json_to_gleam(expected);
1812 let _ = writeln!(
1813 out,
1814 " {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
1815 );
1816 }
1817 }
1818 "ends_with" => {
1819 if let Some(expected) = &assertion.value {
1820 let gleam_val = json_to_gleam(expected);
1821 let _ = writeln!(
1822 out,
1823 " {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
1824 );
1825 }
1826 }
1827 "min_length" => {
1828 if let Some(val) = &assertion.value {
1829 if let Some(n) = val.as_u64() {
1830 let _ = writeln!(
1831 out,
1832 " {field_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1833 );
1834 }
1835 }
1836 }
1837 "max_length" => {
1838 if let Some(val) = &assertion.value {
1839 if let Some(n) = val.as_u64() {
1840 let _ = writeln!(
1841 out,
1842 " {field_expr} |> string.length |> fn(n__) {{ n__ <= {n} }} |> should.equal(True)"
1843 );
1844 }
1845 }
1846 }
1847 "count_min" => {
1848 if let Some(val) = &assertion.value {
1849 if let Some(n) = val.as_u64() {
1850 let _ = writeln!(
1851 out,
1852 " {field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1853 );
1854 }
1855 }
1856 }
1857 "count_equals" => {
1858 if let Some(val) = &assertion.value {
1859 if let Some(n) = val.as_u64() {
1860 let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
1861 }
1862 }
1863 }
1864 "is_true" => {
1865 let _ = writeln!(out, " {field_expr} |> should.equal(True)");
1866 }
1867 "is_false" => {
1868 let _ = writeln!(out, " {field_expr} |> should.equal(False)");
1869 }
1870 "not_error" => {
1871 }
1873 "error" => {
1874 }
1876 "greater_than" => {
1877 if let Some(val) = &assertion.value {
1878 let gleam_val = json_to_gleam(val);
1879 let _ = writeln!(
1880 out,
1881 " {field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1882 );
1883 }
1884 }
1885 "less_than" => {
1886 if let Some(val) = &assertion.value {
1887 let gleam_val = json_to_gleam(val);
1888 let _ = writeln!(
1889 out,
1890 " {field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1891 );
1892 }
1893 }
1894 "greater_than_or_equal" => {
1895 if let Some(val) = &assertion.value {
1896 let gleam_val = json_to_gleam(val);
1897 let _ = writeln!(
1898 out,
1899 " {field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1900 );
1901 }
1902 }
1903 "less_than_or_equal" => {
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 "contains_any" => {
1913 if let Some(values) = &assertion.values {
1914 let vals_list = values.iter().map(json_to_gleam).collect::<Vec<_>>().join(", ");
1915 let _ = writeln!(
1916 out,
1917 " [{vals_list}] |> list.any(fn(v__) {{ string.contains({field_expr}, v__) }}) |> should.equal(True)"
1918 );
1919 }
1920 }
1921 "matches_regex" => {
1922 let _ = writeln!(out, " // regex match not yet implemented for Gleam");
1923 }
1924 "method_result" => {
1925 let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
1926 }
1927 other => {
1928 panic!("Gleam e2e generator: unsupported assertion type: {other}");
1929 }
1930 }
1931}
1932
1933fn json_to_gleam(value: &serde_json::Value) -> String {
1935 match value {
1936 serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
1937 serde_json::Value::Bool(b) => {
1938 if *b {
1939 "True".to_string()
1940 } else {
1941 "False".to_string()
1942 }
1943 }
1944 serde_json::Value::Number(n) => n.to_string(),
1945 serde_json::Value::Null => "Nil".to_string(),
1946 serde_json::Value::Array(arr) => {
1947 let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
1948 format!("[{}]", items.join(", "))
1949 }
1950 serde_json::Value::Object(_) => {
1951 let json_str = serde_json::to_string(value).unwrap_or_default();
1952 format!("\"{}\"", escape_gleam(&json_str))
1953 }
1954 }
1955}
1956
1957#[cfg(test)]
1958mod tests {
1959 use super::*;
1960 use alef_core::config::{GleamElementConstructor, GleamElementField};
1961
1962 fn batch_file_item_recipe() -> GleamElementConstructor {
1963 GleamElementConstructor {
1964 element_type: "BatchFileItem".to_string(),
1965 constructor: "kreuzberg.BatchFileItem".to_string(),
1966 fields: vec![
1967 GleamElementField {
1968 gleam_field: "path".to_string(),
1969 kind: "file_path".to_string(),
1970 json_field: Some("path".to_string()),
1971 default: None,
1972 value: None,
1973 },
1974 GleamElementField {
1975 gleam_field: "config".to_string(),
1976 kind: "literal".to_string(),
1977 json_field: None,
1978 default: None,
1979 value: Some("option.None".to_string()),
1980 },
1981 ],
1982 }
1983 }
1984
1985 #[test]
1986 fn render_element_constructor_file_path_relative_path_gets_test_documents_prefix() {
1987 let item = serde_json::json!({ "path": "docx/fake.docx" });
1988 let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
1989 assert_eq!(
1990 out,
1991 "kreuzberg.BatchFileItem(path: \"../../test_documents/docx/fake.docx\", config: option.None)"
1992 );
1993 }
1994
1995 #[test]
1996 fn render_element_constructor_file_path_absolute_path_passes_through() {
1997 let item = serde_json::json!({ "path": "/etc/some/absolute" });
1998 let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
1999 assert!(
2000 out.contains("\"/etc/some/absolute\""),
2001 "absolute paths must NOT receive the test_documents prefix; got:\n{out}"
2002 );
2003 }
2004
2005 #[test]
2006 fn render_element_constructor_byte_array_emits_bitarray() {
2007 let recipe = GleamElementConstructor {
2008 element_type: "BatchBytesItem".to_string(),
2009 constructor: "kreuzberg.BatchBytesItem".to_string(),
2010 fields: vec![
2011 GleamElementField {
2012 gleam_field: "content".to_string(),
2013 kind: "byte_array".to_string(),
2014 json_field: Some("content".to_string()),
2015 default: None,
2016 value: None,
2017 },
2018 GleamElementField {
2019 gleam_field: "mime_type".to_string(),
2020 kind: "string".to_string(),
2021 json_field: Some("mime_type".to_string()),
2022 default: Some("text/plain".to_string()),
2023 value: None,
2024 },
2025 GleamElementField {
2026 gleam_field: "config".to_string(),
2027 kind: "literal".to_string(),
2028 json_field: None,
2029 default: None,
2030 value: Some("option.None".to_string()),
2031 },
2032 ],
2033 };
2034 let item = serde_json::json!({ "content": [72, 105], "mime_type": "text/html" });
2035 let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
2036 assert_eq!(
2037 out,
2038 "kreuzberg.BatchBytesItem(content: <<72, 105>>, mime_type: \"text/html\", config: option.None)"
2039 );
2040 }
2041
2042 #[test]
2043 fn build_args_with_json_object_wrapper_substitutes_placeholder() {
2044 use crate::config::ArgMapping;
2045 let arg = ArgMapping {
2046 name: "config".to_string(),
2047 field: "config".to_string(),
2048 arg_type: "json_object".to_string(),
2049 optional: false,
2050 owned: false,
2051 element_type: None,
2052 go_type: None,
2053 };
2054 let input = serde_json::json!({
2055 "config": { "use_cache": true, "force_ocr": false }
2056 });
2057 let Some((_setup, args_str)) = build_args_and_setup(
2058 &input,
2059 &[arg],
2060 "test_fixture",
2061 "../../test_documents",
2062 &[],
2063 Some("k.config_from_json_string({json})"),
2064 "kreuzberg",
2065 &[],
2066 ) else {
2067 panic!("expected Some result from build_args_and_setup");
2068 };
2069 assert!(
2072 args_str.starts_with("k.config_from_json_string("),
2073 "wrapper must envelop the JSON literal; got:\n{args_str}"
2074 );
2075 assert!(
2076 args_str.contains("use_cache"),
2077 "JSON payload must reach the wrapper; got:\n{args_str}"
2078 );
2079 }
2080
2081 #[test]
2082 fn build_args_without_json_object_wrapper_emits_bare_json_string() {
2083 use crate::config::ArgMapping;
2084 let arg = ArgMapping {
2085 name: "config".to_string(),
2086 field: "config".to_string(),
2087 arg_type: "json_object".to_string(),
2088 optional: false,
2089 owned: false,
2090 element_type: None,
2091 go_type: None,
2092 };
2093 let input = serde_json::json!({ "config": { "x": 1 } });
2094 let Some((_setup, args_str)) = build_args_and_setup(
2095 &input,
2096 &[arg],
2097 "test_fixture",
2098 "../../test_documents",
2099 &[],
2100 None,
2101 "kreuzberg",
2102 &[],
2103 ) else {
2104 panic!("expected Some result from build_args_and_setup");
2105 };
2106 assert!(
2109 !args_str.contains("from_json_string"),
2110 "no wrapper configured must not synthesise one; got:\n{args_str}"
2111 );
2112 assert!(
2113 args_str.starts_with('"'),
2114 "bare emission is a Gleam string literal starting with a quote; got:\n{args_str}"
2115 );
2116 }
2117
2118 #[test]
2119 fn render_element_constructor_string_falls_back_to_default() {
2120 let recipe = GleamElementConstructor {
2121 element_type: "BatchBytesItem".to_string(),
2122 constructor: "k.BatchBytesItem".to_string(),
2123 fields: vec![GleamElementField {
2124 gleam_field: "mime_type".to_string(),
2125 kind: "string".to_string(),
2126 json_field: Some("mime_type".to_string()),
2127 default: Some("text/plain".to_string()),
2128 value: None,
2129 }],
2130 };
2131 let item = serde_json::json!({});
2132 let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
2133 assert!(
2134 out.contains("mime_type: \"text/plain\""),
2135 "missing string field must fall back to default; got:\n{out}"
2136 );
2137 }
2138}