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