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{{ok, _}} = application:ensure_all_started(elixir),\n\
115 \x20\x20\x20\x20{{ok, _}} = application:ensure_all_started({app_name}),\n\
116 \x20\x20\x20\x20nil.\n",
117 );
118 files.push(GeneratedFile {
119 path: output_base.join("src").join("e2e_gleam.gleam"),
120 content: e2e_helpers,
121 generated_header: false,
122 });
123 files.push(GeneratedFile {
124 path: output_base.join("src").join("e2e_startup.erl"),
125 content: erlang_startup,
126 generated_header: false,
127 });
128
129 let mut any_tests = false;
131
132 for group in groups {
134 let active: Vec<&Fixture> = group
135 .fixtures
136 .iter()
137 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
139 .filter(|f| {
143 if let Some(http) = &f.http {
144 let has_upgrade = http
145 .request
146 .headers
147 .iter()
148 .any(|(k, v)| k.eq_ignore_ascii_case("upgrade") && v.eq_ignore_ascii_case("websocket"));
149 !has_upgrade
150 } else {
151 true
152 }
153 })
154 .collect();
157
158 if active.is_empty() {
159 continue;
160 }
161
162 let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
163 let field_resolver = FieldResolver::new(
164 &e2e_config.fields,
165 &e2e_config.fields_optional,
166 &e2e_config.result_fields,
167 &e2e_config.fields_array,
168 &e2e_config.fields_method_calls,
169 );
170 let element_constructors: &[alef_core::config::GleamElementConstructor] = config
173 .gleam
174 .as_ref()
175 .map(|g| g.element_constructors.as_slice())
176 .unwrap_or(&[]);
177 let json_object_wrapper: Option<&str> =
180 config.gleam.as_ref().and_then(|g| g.json_object_wrapper.as_deref());
181 let content = render_test_file(
182 &group.category,
183 &active,
184 e2e_config,
185 &module_path,
186 &function_name,
187 result_var,
188 &e2e_config.call.args,
189 &field_resolver,
190 &e2e_config.fields_enum,
191 element_constructors,
192 json_object_wrapper,
193 );
194 files.push(GeneratedFile {
195 path: output_base.join("test").join(filename),
196 content,
197 generated_header: true,
198 });
199 any_tests = true;
200 }
201
202 let entry = if any_tests {
207 concat!(
208 "// Generated by alef. Do not edit by hand.\n",
209 "import gleeunit\n",
210 "import e2e_gleam\n",
211 "\n",
212 "pub fn main() {\n",
213 " let _ = e2e_gleam.start_app()\n",
214 " gleeunit.main()\n",
215 "}\n",
216 )
217 .to_string()
218 } else {
219 concat!(
220 "// Generated by alef. Do not edit by hand.\n",
221 "// No fixture-driven tests for Gleam — e2e tests require HTTP fixtures\n",
222 "// or non-HTTP fixtures with gleam-specific call overrides.\n",
223 "import gleeunit\n",
224 "import gleeunit/should\n",
225 "\n",
226 "pub fn main() {\n",
227 " gleeunit.main()\n",
228 "}\n",
229 "\n",
230 "pub fn compilation_smoke_test() {\n",
231 " True |> should.equal(True)\n",
232 "}\n",
233 )
234 .to_string()
235 };
236 files.push(GeneratedFile {
237 path: output_base.join("test").join("e2e_gleam_test.gleam"),
238 content: entry,
239 generated_header: false,
240 });
241
242 Ok(files)
243 }
244
245 fn language_name(&self) -> &'static str {
246 "gleam"
247 }
248}
249
250fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
255 use alef_core::template_versions::hex;
256 let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
257 let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
258 let gleam_httpc = hex::GLEAM_HTTPC_VERSION_RANGE;
259 let envoy = hex::ENVOY_VERSION_RANGE;
260 let deps = match dep_mode {
261 crate::config::DependencyMode::Registry => {
262 format!(
263 r#"{pkg_name} = ">= 0.1.0"
264gleam_stdlib = "{stdlib}"
265gleeunit = "{gleeunit}"
266gleam_httpc = "{gleam_httpc}"
267gleam_http = ">= 4.0.0 and < 5.0.0"
268envoy = "{envoy}""#
269 )
270 }
271 crate::config::DependencyMode::Local => {
272 format!(
273 r#"{pkg_name} = {{ path = "{pkg_path}" }}
274gleam_stdlib = "{stdlib}"
275gleeunit = "{gleeunit}"
276gleam_httpc = "{gleam_httpc}"
277gleam_http = ">= 4.0.0 and < 5.0.0"
278envoy = "{envoy}""#
279 )
280 }
281 };
282
283 format!(
284 r#"name = "e2e_gleam"
285version = "0.1.0"
286target = "erlang"
287
288[dependencies]
289{deps}
290"#
291 )
292}
293
294#[allow(clippy::too_many_arguments)]
295fn render_test_file(
296 _category: &str,
297 fixtures: &[&Fixture],
298 e2e_config: &E2eConfig,
299 module_path: &str,
300 function_name: &str,
301 result_var: &str,
302 args: &[crate::config::ArgMapping],
303 field_resolver: &FieldResolver,
304 enum_fields: &HashSet<String>,
305 element_constructors: &[alef_core::config::GleamElementConstructor],
306 json_object_wrapper: Option<&str>,
307) -> String {
308 let mut out = String::new();
309 out.push_str(&hash::header(CommentStyle::DoubleSlash));
310 let _ = writeln!(out, "import gleeunit");
311 let _ = writeln!(out, "import gleeunit/should");
312
313 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
315
316 if has_http_fixtures {
318 let _ = writeln!(out, "import gleam/httpc");
319 let _ = writeln!(out, "import gleam/http");
320 let _ = writeln!(out, "import gleam/http/request");
321 let _ = writeln!(out, "import gleam/list");
322 let _ = writeln!(out, "import gleam/result");
323 let _ = writeln!(out, "import gleam/string");
324 let _ = writeln!(out, "import envoy");
325 }
326
327 let has_non_http_with_override = fixtures.iter().any(|f| !f.is_http_test());
329 if has_non_http_with_override {
330 let _ = writeln!(out, "import {module_path}");
331 let _ = writeln!(out, "import e2e_gleam");
332 let needs_envoy_for_binding = !has_http_fixtures
334 && fixtures.iter().filter(|f| !f.is_http_test()).any(|f| {
335 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
336 cc.args.iter().any(|a| a.arg_type == "mock_url")
337 });
338 if needs_envoy_for_binding {
339 let _ = writeln!(out, "import envoy");
340 }
341 }
342 let _ = writeln!(out);
343
344 let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
346
347 for fixture in fixtures {
349 if fixture.is_http_test() {
350 continue; }
352 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
354 let has_bytes_arg = call_config.args.iter().any(|a| a.arg_type == "bytes");
355 let has_optional_string_arg = call_config.args.iter().any(|a| a.arg_type == "string" && a.optional);
357 let has_json_object_arg = call_config.args.iter().any(|a| a.arg_type == "json_object");
359 let has_handle_arg = call_config.args.iter().any(|a| a.arg_type == "handle");
361 if has_bytes_arg || has_optional_string_arg || has_json_object_arg || has_handle_arg {
362 needed_modules.insert("option");
363 }
364 for assertion in &fixture.assertions {
365 let needs_case_expr = assertion
368 .field
369 .as_deref()
370 .is_some_and(|f| field_resolver.tagged_union_split(f).is_some());
371 if needs_case_expr {
372 needed_modules.insert("option");
373 }
374 if let Some(f) = &assertion.field {
376 if field_resolver.is_optional(field_resolver.resolve(f)) {
377 needed_modules.insert("option");
378 }
379 }
380 match assertion.assertion_type.as_str() {
381 "contains_any" => {
382 needed_modules.insert("string");
384 needed_modules.insert("list");
385 }
386 "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" => {
387 needed_modules.insert("string");
388 if let Some(f) = &assertion.field {
390 let resolved = field_resolver.resolve(f);
391 if field_resolver.is_array(f) || field_resolver.is_array(resolved) {
392 needed_modules.insert("list");
393 }
394 } else {
395 if call_config.result_is_array
397 || call_config.result_is_vec
398 || field_resolver.is_array("")
399 || field_resolver.is_array(field_resolver.resolve(""))
400 {
401 needed_modules.insert("list");
402 }
403 }
404 }
405 "not_empty" | "is_empty" => {
406 if let Some(f) = &assertion.field {
408 let resolved = field_resolver.resolve(f);
409 let is_opt = field_resolver.is_optional(resolved);
410 let is_arr = field_resolver.is_array(f) || field_resolver.is_array(resolved);
411 if is_arr {
412 needed_modules.insert("list");
413 } else if is_opt {
414 needed_modules.insert("option");
415 } else {
416 needed_modules.insert("string");
417 }
418 } else {
419 needed_modules.insert("list");
420 }
421 }
422 "count_min" | "count_equals" => {
423 needed_modules.insert("list");
424 }
426 "min_length" | "max_length" => {
427 needed_modules.insert("string");
428 }
430 "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
431 }
433 _ => {}
434 }
435 if needs_case_expr {
437 if let Some(f) = &assertion.field {
438 let resolved = field_resolver.resolve(f);
439 if field_resolver.is_array(resolved) {
440 needed_modules.insert("list");
441 }
442 }
443 }
444 if let Some(f) = &assertion.field {
446 if f.split('.').any(|seg| seg == "length") {
447 needed_modules.insert("list");
448 }
449 }
450 if let Some(f) = &assertion.field {
453 if !f.is_empty() {
454 let parts: Vec<&str> = f.split('.').collect();
455 let has_opt_prefix = (1..parts.len()).any(|i| {
456 let prefix_path = parts[..i].join(".");
457 field_resolver.is_optional(&prefix_path)
458 });
459 if has_opt_prefix {
460 needed_modules.insert("option");
461 if matches!(assertion.assertion_type.as_str(), "not_empty" | "is_empty") {
464 let resolved = field_resolver.resolve(f);
465 if field_resolver.is_array(f) || field_resolver.is_array(resolved) {
466 needed_modules.insert("list");
467 } else {
468 needed_modules.insert("string");
469 }
470 }
471 }
472 }
473 }
474 }
475 }
476
477 for module in &needed_modules {
479 let _ = writeln!(out, "import gleam/{module}");
480 }
481
482 if !needed_modules.is_empty() {
483 let _ = writeln!(out);
484 }
485
486 for fixture in fixtures {
488 if fixture.is_http_test() {
489 render_http_test_case(&mut out, fixture);
490 } else {
491 render_test_case(
492 &mut out,
493 fixture,
494 e2e_config,
495 module_path,
496 function_name,
497 result_var,
498 args,
499 field_resolver,
500 enum_fields,
501 element_constructors,
502 json_object_wrapper,
503 );
504 }
505 let _ = writeln!(out);
506 }
507
508 out
509}
510
511struct GleamTestClientRenderer;
516
517impl client::TestClientRenderer for GleamTestClientRenderer {
518 fn language_name(&self) -> &'static str {
519 "gleam"
520 }
521
522 fn sanitize_test_name(&self, id: &str) -> String {
527 let raw = sanitize_ident(id);
528 let stripped = raw.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
529 if stripped.is_empty() { raw } else { stripped.to_string() }
530 }
531
532 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
538 let _ = writeln!(out, "// {description}");
539 let _ = writeln!(out, "pub fn {fn_name}_test() {{");
540 if let Some(reason) = skip_reason {
541 let escaped = escape_gleam(reason);
544 let _ = writeln!(out, " // skipped: {escaped}");
545 let _ = writeln!(out, " Nil");
546 }
547 }
548
549 fn render_test_close(&self, out: &mut String) {
551 let _ = writeln!(out, "}}");
552 }
553
554 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
560 let path = ctx.path;
561
562 let _ = writeln!(out, " let base_url = case envoy.get(\"MOCK_SERVER_URL\") {{");
564 let _ = writeln!(out, " Ok(u) -> u");
565 let _ = writeln!(out, " Error(_) -> \"http://localhost:8080\"");
566 let _ = writeln!(out, " }}");
567
568 let _ = writeln!(out, " let assert Ok(req) = request.to(base_url <> \"{path}\")");
570
571 let method_const = match ctx.method.to_uppercase().as_str() {
573 "GET" => "Get",
574 "POST" => "Post",
575 "PUT" => "Put",
576 "DELETE" => "Delete",
577 "PATCH" => "Patch",
578 "HEAD" => "Head",
579 "OPTIONS" => "Options",
580 _ => "Post",
581 };
582 let _ = writeln!(out, " let req = request.set_method(req, http.{method_const})");
583
584 if ctx.body.is_some() {
586 let content_type = ctx.content_type.unwrap_or("application/json");
587 let escaped_ct = escape_gleam(content_type);
588 let _ = writeln!(
589 out,
590 " let req = request.set_header(req, \"content-type\", \"{escaped_ct}\")"
591 );
592 }
593
594 for (name, value) in ctx.headers {
596 let lower = name.to_lowercase();
597 if matches!(lower.as_str(), "content-length" | "host" | "transfer-encoding") {
598 continue;
599 }
600 let escaped_name = escape_gleam(name);
601 let escaped_value = escape_gleam(value);
602 let _ = writeln!(
603 out,
604 " let req = request.set_header(req, \"{escaped_name}\", \"{escaped_value}\")"
605 );
606 }
607
608 if !ctx.cookies.is_empty() {
610 let cookie_str: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
611 let escaped_cookie = escape_gleam(&cookie_str.join("; "));
612 let _ = writeln!(
613 out,
614 " let req = request.set_header(req, \"cookie\", \"{escaped_cookie}\")"
615 );
616 }
617
618 if let Some(body) = ctx.body {
620 let json_str = serde_json::to_string(body).unwrap_or_default();
621 let escaped = escape_gleam(&json_str);
622 let _ = writeln!(out, " let req = request.set_body(req, \"{escaped}\")");
623 }
624
625 let resp = ctx.response_var;
627 let _ = writeln!(out, " let assert Ok({resp}) = httpc.send(req)");
628 }
629
630 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
632 let _ = writeln!(out, " {response_var}.status |> should.equal({status})");
633 }
634
635 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
641 let escaped_name = escape_gleam(&name.to_lowercase());
642 match expected {
643 "<<absent>>" => {
644 let _ = writeln!(
645 out,
646 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_false()"
647 );
648 }
649 "<<present>>" | "<<uuid>>" => {
650 let _ = writeln!(
652 out,
653 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_true()"
654 );
655 }
656 literal => {
657 let _escaped_value = escape_gleam(literal);
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_true()"
663 );
664 }
665 }
666 }
667
668 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
674 let escaped = match expected {
675 serde_json::Value::String(s) => escape_gleam(s),
676 other => escape_gleam(&serde_json::to_string(other).unwrap_or_default()),
677 };
678 let _ = writeln!(
679 out,
680 " {response_var}.body |> string.trim |> should.equal(\"{escaped}\")"
681 );
682 }
683
684 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
690 if let Some(obj) = expected.as_object() {
691 for (key, val) in obj {
692 let fragment = escape_gleam(&format!("\"{}\":", key));
693 let _ = writeln!(
694 out,
695 " {response_var}.body |> string.contains(\"{fragment}\") |> should.equal(True)"
696 );
697 let _ = val; }
699 }
700 }
701
702 fn render_assert_validation_errors(
708 &self,
709 out: &mut String,
710 response_var: &str,
711 errors: &[ValidationErrorExpectation],
712 ) {
713 for err in errors {
714 let escaped_msg = escape_gleam(&err.msg);
715 let _ = writeln!(
716 out,
717 " {response_var}.body |> string.contains(\"{escaped_msg}\") |> should.equal(True)"
718 );
719 }
720 }
721}
722
723fn render_http_test_case(out: &mut String, fixture: &Fixture) {
729 client::http_call::render_http_test(out, &GleamTestClientRenderer, fixture);
730}
731
732#[allow(clippy::too_many_arguments)]
733fn render_test_case(
734 out: &mut String,
735 fixture: &Fixture,
736 e2e_config: &E2eConfig,
737 module_path: &str,
738 _function_name: &str,
739 _result_var: &str,
740 _args: &[crate::config::ArgMapping],
741 field_resolver: &FieldResolver,
742 enum_fields: &HashSet<String>,
743 element_constructors: &[alef_core::config::GleamElementConstructor],
744 json_object_wrapper: Option<&str>,
745) {
746 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
748 let lang = "gleam";
749 let call_overrides = call_config.overrides.get(lang);
750 let function_name = call_overrides
751 .and_then(|o| o.function.as_ref())
752 .cloned()
753 .unwrap_or_else(|| call_config.function.clone());
754 let result_var = &call_config.result_var;
755 let args = &call_config.args;
756
757 let raw_name = sanitize_ident(&fixture.id);
762 let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
763 let test_name = if stripped.is_empty() {
764 raw_name.as_str()
765 } else {
766 stripped
767 };
768 let description = &fixture.description;
769 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
770
771 let test_documents_path = e2e_config.test_documents_relative_from(0);
772 let (setup_lines, args_str) = build_args_and_setup(
773 &fixture.input,
774 args,
775 &fixture.id,
776 &test_documents_path,
777 element_constructors,
778 json_object_wrapper,
779 module_path,
780 );
781
782 let _ = writeln!(out, "// {description}");
785 let _ = writeln!(out, "pub fn {test_name}_test() {{");
786
787 for line in &setup_lines {
788 let _ = writeln!(out, " {line}");
789 }
790
791 if expects_error {
792 let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
793 let _ = writeln!(out, "}}");
794 return;
795 }
796
797 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
798 let _ = writeln!(out, " {result_var} |> should.be_ok()");
799 let _ = writeln!(out, " let assert Ok(r) = {result_var}");
800
801 let result_is_array = call_config.result_is_array || call_config.result_is_vec;
802 let pkg_module = e2e_config
807 .resolve_package("gleam")
808 .as_ref()
809 .and_then(|p| p.name.as_ref())
810 .cloned()
811 .unwrap_or_else(|| module_path.split('.').next().unwrap_or(module_path).to_string());
812 for assertion in &fixture.assertions {
813 render_assertion(
814 out,
815 assertion,
816 "r",
817 field_resolver,
818 enum_fields,
819 result_is_array,
820 &pkg_module,
821 );
822 }
823
824 let _ = writeln!(out, "}}");
825}
826
827fn build_args_and_setup(
838 input: &serde_json::Value,
839 args: &[crate::config::ArgMapping],
840 fixture_id: &str,
841 test_documents_path: &str,
842 element_constructors: &[alef_core::config::GleamElementConstructor],
843 json_object_wrapper: Option<&str>,
844 module_path: &str,
845) -> (Vec<String>, String) {
846 if args.is_empty() {
847 return (Vec::new(), String::new());
848 }
849
850 let mut setup_lines: Vec<String> = Vec::new();
851 let mut parts: Vec<String> = Vec::new();
852 let mut bytes_var_counter = 0usize;
853
854 for arg in args {
855 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
856 let val = input.get(field);
857
858 match arg.arg_type.as_str() {
859 "handle" => {
860 let name = &arg.name;
864 let constructor = format!("create_{}", name.to_snake_case());
865 setup_lines.push(format!(
866 "let assert Ok({name}) = {module_path}.{constructor}(option.None)"
867 ));
868 parts.push(name.clone());
869 continue;
870 }
871 "mock_url" => {
872 let name = &arg.name;
874 setup_lines.push(format!(
875 "let {name} = case envoy.get(\"MOCK_SERVER_URL\") {{ Ok(base) -> base <> \"/fixtures/{fixture_id}\" Error(_) -> \"http://localhost:8080/fixtures/{fixture_id}\" }}"
876 ));
877 parts.push(name.clone());
878 continue;
879 }
880 "file_path" => {
881 let path = val.and_then(|v| v.as_str()).unwrap_or("");
885 let full_path = format!("{test_documents_path}/{path}");
886 parts.push(format!("\"{}\"", escape_gleam(&full_path)));
887 }
888 "bytes" => {
889 let path = val.and_then(|v| v.as_str()).unwrap_or("");
893 let var_name = if bytes_var_counter == 0 {
894 "data_bytes__".to_string()
895 } else {
896 format!("data_bytes_{bytes_var_counter}__")
897 };
898 bytes_var_counter += 1;
899 let full_path = format!("{test_documents_path}/{path}");
901 setup_lines.push(format!(
902 "let assert Ok({var_name}) = e2e_gleam.read_file_bytes(\"{}\")",
903 escape_gleam(&full_path)
904 ));
905 parts.push(var_name);
906 }
907 "string" if arg.optional => {
908 match val {
910 None | Some(serde_json::Value::Null) => {
911 parts.push("option.None".to_string());
912 }
913 Some(serde_json::Value::String(s)) if s.is_empty() => {
914 parts.push("option.None".to_string());
915 }
916 Some(serde_json::Value::String(s)) => {
917 parts.push(format!("option.Some(\"{}\")", escape_gleam(s)));
918 }
919 Some(v) => {
920 parts.push(format!("option.Some({})", json_to_gleam(v)));
921 }
922 }
923 }
924 "string" => {
925 match val {
927 None | Some(serde_json::Value::Null) => {
928 parts.push("\"\"".to_string());
929 }
930 Some(serde_json::Value::String(s)) => {
931 parts.push(format!("\"{}\"", escape_gleam(s)));
932 }
933 Some(v) => {
934 parts.push(json_to_gleam(v));
935 }
936 }
937 }
938 "json_object" => {
939 let element_type = arg.element_type.as_deref().unwrap_or("");
944 let recipe = if element_type.is_empty() {
945 None
946 } else {
947 element_constructors.iter().find(|r| r.element_type == element_type)
948 };
949
950 if let Some(recipe) = recipe {
951 let items_expr = match val {
955 Some(serde_json::Value::Array(arr)) => {
956 let items: Vec<String> = arr
957 .iter()
958 .map(|item| render_gleam_element_constructor(item, recipe, test_documents_path))
959 .collect();
960 format!("[{}]", items.join(", "))
961 }
962 _ => "[]".to_string(),
963 };
964 if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
965 parts.push("[]".to_string());
966 } else {
967 parts.push(items_expr);
968 }
969 } else if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
970 parts.push("option.None".to_string());
971 } else {
972 let empty_obj = serde_json::Value::Object(Default::default());
973 let config_val = val.unwrap_or(&empty_obj);
974 let json_literal = json_to_gleam(config_val);
975 let emitted = match json_object_wrapper {
980 Some(template) => template.replace("{json}", &json_literal),
981 None => json_literal,
982 };
983 parts.push(emitted);
984 }
985 }
986 "int" | "integer" => match val {
987 None | Some(serde_json::Value::Null) if arg.optional => {}
988 None | Some(serde_json::Value::Null) => parts.push("0".to_string()),
989 Some(v) => parts.push(json_to_gleam(v)),
990 },
991 "bool" | "boolean" => match val {
992 Some(serde_json::Value::Bool(true)) => parts.push("True".to_string()),
993 Some(serde_json::Value::Bool(false)) | None | Some(serde_json::Value::Null) => {
994 if !arg.optional {
995 parts.push("False".to_string());
996 }
997 }
998 Some(v) => parts.push(json_to_gleam(v)),
999 },
1000 _ => {
1001 match val {
1003 None | Some(serde_json::Value::Null) if arg.optional => {}
1004 None | Some(serde_json::Value::Null) => parts.push("Nil".to_string()),
1005 Some(v) => parts.push(json_to_gleam(v)),
1006 }
1007 }
1008 }
1009 }
1010
1011 (setup_lines, parts.join(", "))
1012}
1013
1014fn render_gleam_element_constructor(
1026 item: &serde_json::Value,
1027 recipe: &alef_core::config::GleamElementConstructor,
1028 test_documents_path: &str,
1029) -> String {
1030 let mut field_exprs: Vec<String> = Vec::with_capacity(recipe.fields.len());
1031 for field in &recipe.fields {
1032 let expr = match field.kind.as_str() {
1033 "file_path" => {
1034 let json_field = field.json_field.as_deref().unwrap_or("");
1035 let path = item.get(json_field).and_then(|v| v.as_str()).unwrap_or("");
1036 let full = if path.starts_with('/') {
1037 path.to_string()
1038 } else {
1039 format!("{test_documents_path}/{path}")
1040 };
1041 format!("\"{}\"", escape_gleam(&full))
1042 }
1043 "byte_array" => {
1044 let json_field = field.json_field.as_deref().unwrap_or("");
1045 let bytes: Vec<String> = item
1046 .get(json_field)
1047 .and_then(|v| v.as_array())
1048 .map(|arr| arr.iter().map(|b| b.as_u64().unwrap_or(0).to_string()).collect())
1049 .unwrap_or_default();
1050 if bytes.is_empty() {
1051 "<<>>".to_string()
1052 } else {
1053 format!("<<{}>>", bytes.join(", "))
1054 }
1055 }
1056 "string" => {
1057 let json_field = field.json_field.as_deref().unwrap_or("");
1058 let value = item
1059 .get(json_field)
1060 .and_then(|v| v.as_str())
1061 .map(str::to_string)
1062 .or_else(|| field.default.clone())
1063 .unwrap_or_default();
1064 format!("\"{}\"", escape_gleam(&value))
1065 }
1066 "literal" => field.value.clone().unwrap_or_default(),
1067 other => {
1068 field
1073 .value
1074 .clone()
1075 .unwrap_or_else(|| format!("\"<unsupported kind: {other}>\""))
1076 }
1077 };
1078 field_exprs.push(format!("{}: {}", field.gleam_field, expr));
1079 }
1080 format!("{}({})", recipe.constructor, field_exprs.join(", "))
1081}
1082
1083#[allow(clippy::too_many_arguments)]
1092fn render_tagged_union_assertion(
1093 out: &mut String,
1094 assertion: &Assertion,
1095 result_var: &str,
1096 prefix: &str,
1097 variant: &str,
1098 suffix: &str,
1099 field_resolver: &FieldResolver,
1100 pkg_module: &str,
1101) {
1102 let prefix_expr = if prefix.is_empty() {
1105 result_var.to_string()
1106 } else {
1107 format!("{result_var}.{prefix}")
1108 };
1109
1110 let constructor = variant.to_pascal_case();
1114 let module_qualifier = pkg_module;
1118
1119 let inner_var = "fmt_inner__";
1121
1122 let full_suffix_path = if prefix.is_empty() {
1125 format!("{variant}.{suffix}")
1126 } else {
1127 format!("{prefix}.{variant}.{suffix}")
1128 };
1129 let suffix_is_optional = field_resolver.is_optional(&full_suffix_path);
1130 let suffix_is_array = field_resolver.is_array(&full_suffix_path);
1131
1132 let _ = writeln!(out, " case {prefix_expr} {{");
1134 let _ = writeln!(
1135 out,
1136 " option.Some({module_qualifier}.{constructor}({inner_var})) -> {{"
1137 );
1138
1139 let inner_field_expr = if suffix.is_empty() {
1141 inner_var.to_string()
1142 } else {
1143 format!("{inner_var}.{suffix}")
1144 };
1145
1146 match assertion.assertion_type.as_str() {
1148 "equals" => {
1149 if let Some(expected) = &assertion.value {
1150 let gleam_val = json_to_gleam(expected);
1151 if suffix_is_optional {
1152 let default = default_gleam_value_for_optional(&gleam_val);
1153 let _ = writeln!(
1154 out,
1155 " {inner_field_expr} |> option.unwrap({default}) |> should.equal({gleam_val})"
1156 );
1157 } else {
1158 let _ = writeln!(out, " {inner_field_expr} |> should.equal({gleam_val})");
1159 }
1160 }
1161 }
1162 "contains" => {
1163 if let Some(expected) = &assertion.value {
1164 let gleam_val = json_to_gleam(expected);
1165 if suffix_is_array {
1166 let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
1168 let _ = writeln!(
1169 out,
1170 " items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1171 );
1172 } else if suffix_is_optional {
1173 let _ = writeln!(
1174 out,
1175 " {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1176 );
1177 } else {
1178 let _ = writeln!(
1179 out,
1180 " {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1181 );
1182 }
1183 }
1184 }
1185 "contains_all" => {
1186 if let Some(values) = &assertion.values {
1187 if suffix_is_array {
1188 let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
1190 for val in values {
1191 let gleam_val = json_to_gleam(val);
1192 let _ = writeln!(
1193 out,
1194 " items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1195 );
1196 }
1197 } else if suffix_is_optional {
1198 for val in values {
1199 let gleam_val = json_to_gleam(val);
1200 let _ = writeln!(
1201 out,
1202 " {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1203 );
1204 }
1205 } else {
1206 for val in values {
1207 let gleam_val = json_to_gleam(val);
1208 let _ = writeln!(
1209 out,
1210 " {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1211 );
1212 }
1213 }
1214 }
1215 }
1216 "greater_than_or_equal" => {
1217 if let Some(val) = &assertion.value {
1218 let gleam_val = json_to_gleam(val);
1219 if suffix_is_optional {
1220 let _ = writeln!(
1221 out,
1222 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1223 );
1224 } else {
1225 let _ = writeln!(
1226 out,
1227 " {inner_field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1228 );
1229 }
1230 }
1231 }
1232 "greater_than" => {
1233 if let Some(val) = &assertion.value {
1234 let gleam_val = json_to_gleam(val);
1235 if suffix_is_optional {
1236 let _ = writeln!(
1237 out,
1238 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1239 );
1240 } else {
1241 let _ = writeln!(
1242 out,
1243 " {inner_field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1244 );
1245 }
1246 }
1247 }
1248 "less_than" => {
1249 if let Some(val) = &assertion.value {
1250 let gleam_val = json_to_gleam(val);
1251 if suffix_is_optional {
1252 let _ = writeln!(
1253 out,
1254 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1255 );
1256 } else {
1257 let _ = writeln!(
1258 out,
1259 " {inner_field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1260 );
1261 }
1262 }
1263 }
1264 "less_than_or_equal" => {
1265 if let Some(val) = &assertion.value {
1266 let gleam_val = json_to_gleam(val);
1267 if suffix_is_optional {
1268 let _ = writeln!(
1269 out,
1270 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1271 );
1272 } else {
1273 let _ = writeln!(
1274 out,
1275 " {inner_field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1276 );
1277 }
1278 }
1279 }
1280 "count_min" => {
1281 if let Some(val) = &assertion.value {
1282 if let Some(n) = val.as_u64() {
1283 if suffix_is_optional {
1284 let _ = writeln!(
1285 out,
1286 " {inner_field_expr} |> option.unwrap([]) |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1287 );
1288 } else {
1289 let _ = writeln!(
1290 out,
1291 " {inner_field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1292 );
1293 }
1294 }
1295 }
1296 }
1297 "count_equals" => {
1298 if let Some(val) = &assertion.value {
1299 if let Some(n) = val.as_u64() {
1300 if suffix_is_optional {
1301 let _ = writeln!(
1302 out,
1303 " {inner_field_expr} |> option.unwrap([]) |> list.length |> should.equal({n})"
1304 );
1305 } else {
1306 let _ = writeln!(out, " {inner_field_expr} |> list.length |> should.equal({n})");
1307 }
1308 }
1309 }
1310 }
1311 "not_empty" => {
1312 if suffix_is_optional {
1313 let _ = writeln!(
1314 out,
1315 " {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(False)"
1316 );
1317 } else if suffix_is_array {
1318 let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(False)");
1319 } else {
1320 let _ = writeln!(
1321 out,
1322 " {inner_field_expr} |> string.is_empty |> should.equal(False)"
1323 );
1324 }
1325 }
1326 "is_empty" => {
1327 if suffix_is_optional {
1328 let _ = writeln!(
1329 out,
1330 " {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(True)"
1331 );
1332 } else if suffix_is_array {
1333 let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(True)");
1334 } else {
1335 let _ = writeln!(out, " {inner_field_expr} |> string.is_empty |> should.equal(True)");
1336 }
1337 }
1338 "is_true" => {
1339 let _ = writeln!(out, " {inner_field_expr} |> should.equal(True)");
1340 }
1341 "is_false" => {
1342 let _ = writeln!(out, " {inner_field_expr} |> should.equal(False)");
1343 }
1344 other => {
1345 let _ = writeln!(
1346 out,
1347 " // tagged-union assertion '{other}' not yet implemented for Gleam"
1348 );
1349 }
1350 }
1351
1352 let _ = writeln!(out, " }}");
1354 let _ = writeln!(
1355 out,
1356 " _ -> panic as \"expected {module_qualifier}.{constructor} format metadata\""
1357 );
1358 let _ = writeln!(out, " }}");
1359}
1360
1361fn default_gleam_value_for_optional(gleam_val: &str) -> &'static str {
1364 if gleam_val.starts_with('"') {
1365 "\"\""
1366 } else if gleam_val == "True" || gleam_val == "False" {
1367 "False"
1368 } else if gleam_val.contains('.') {
1369 "0.0"
1370 } else {
1371 "0"
1372 }
1373}
1374
1375fn render_assertion(
1376 out: &mut String,
1377 assertion: &Assertion,
1378 result_var: &str,
1379 field_resolver: &FieldResolver,
1380 enum_fields: &HashSet<String>,
1381 result_is_array: bool,
1382 pkg_module: &str,
1383) {
1384 if let Some(f) = &assertion.field {
1386 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1387 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1388 return;
1389 }
1390 }
1391
1392 if let Some(f) = &assertion.field {
1394 if f.contains("[].") || f.contains("[0].") {
1395 let _ = writeln!(
1396 out,
1397 " // skipped: array element field '{f}' not yet supported in Gleam e2e"
1398 );
1399 return;
1400 }
1401 }
1402
1403 if let Some(f) = &assertion.field {
1407 if !f.is_empty() {
1408 if let Some((prefix, variant, suffix)) = field_resolver.tagged_union_split(f) {
1409 render_tagged_union_assertion(
1410 out,
1411 assertion,
1412 result_var,
1413 &prefix,
1414 &variant,
1415 &suffix,
1416 field_resolver,
1417 pkg_module,
1418 );
1419 return;
1420 }
1421 }
1422 }
1423
1424 if let Some(f) = &assertion.field {
1427 if !f.is_empty() {
1428 let parts: Vec<&str> = f.split('.').collect();
1429 let mut opt_prefix: Option<(String, usize)> = None;
1430 for i in 1..parts.len() {
1431 let prefix_path = parts[..i].join(".");
1432 if field_resolver.is_optional(&prefix_path) {
1433 opt_prefix = Some((prefix_path, i));
1434 break;
1435 }
1436 }
1437 if let Some((optional_prefix, suffix_start)) = opt_prefix {
1438 let prefix_expr = format!("{result_var}.{optional_prefix}");
1439 let suffix_parts = &parts[suffix_start..];
1440 let suffix_str = suffix_parts.join(".");
1441 let inner_var = "opt_inner__";
1442 let inner_expr = if suffix_str.is_empty() {
1443 inner_var.to_string()
1444 } else {
1445 format!("{inner_var}.{suffix_str}")
1446 };
1447 let _ = writeln!(out, " case {prefix_expr} {{");
1448 let _ = writeln!(out, " option.Some({inner_var}) -> {{");
1449 match assertion.assertion_type.as_str() {
1450 "count_min" => {
1451 if let Some(val) = &assertion.value {
1452 if let Some(n) = val.as_u64() {
1453 let _ = writeln!(
1454 out,
1455 " {inner_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1456 );
1457 }
1458 }
1459 }
1460 "count_equals" => {
1461 if let Some(val) = &assertion.value {
1462 if let Some(n) = val.as_u64() {
1463 let _ = writeln!(out, " {inner_expr} |> list.length |> should.equal({n})");
1464 }
1465 }
1466 }
1467 "not_empty" => {
1468 let is_arr = field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f));
1469 if is_arr {
1470 let _ = writeln!(out, " {inner_expr} |> list.is_empty |> should.equal(False)");
1471 } else {
1472 let _ = writeln!(out, " {inner_expr} |> string.is_empty |> should.equal(False)");
1473 }
1474 }
1475 "min_length" => {
1476 if let Some(val) = &assertion.value {
1477 if let Some(n) = val.as_u64() {
1478 let _ = writeln!(
1479 out,
1480 " {inner_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1481 );
1482 }
1483 }
1484 }
1485 other => {
1486 let _ = writeln!(
1487 out,
1488 " // optional-prefix assertion '{other}' not yet implemented for Gleam"
1489 );
1490 }
1491 }
1492 let _ = writeln!(out, " }}");
1493 let _ = writeln!(out, " option.None -> should.fail()");
1494 let _ = writeln!(out, " }}");
1495 return;
1496 }
1497 }
1498 }
1499
1500 let field_is_optional = assertion
1503 .field
1504 .as_deref()
1505 .is_some_and(|f| !f.is_empty() && field_resolver.is_optional(field_resolver.resolve(f)));
1506
1507 let _field_is_enum = assertion
1509 .field
1510 .as_deref()
1511 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1512
1513 let field_expr = match &assertion.field {
1514 Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
1515 _ => result_var.to_string(),
1516 };
1517
1518 let field_is_array = {
1521 let f = assertion.field.as_deref().unwrap_or("");
1522 let is_root = f.is_empty();
1523 (is_root && result_is_array) || field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f))
1524 };
1525
1526 match assertion.assertion_type.as_str() {
1527 "equals" => {
1528 if let Some(expected) = &assertion.value {
1529 let gleam_val = json_to_gleam(expected);
1530 if field_is_optional {
1531 let _ = writeln!(out, " {field_expr} |> should.equal(option.Some({gleam_val}))");
1533 } else {
1534 let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
1535 }
1536 }
1537 }
1538 "contains" => {
1539 if let Some(expected) = &assertion.value {
1540 let gleam_val = json_to_gleam(expected);
1541 if field_is_array {
1542 let _ = writeln!(
1544 out,
1545 " {field_expr} |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1546 );
1547 } else if field_is_optional {
1548 let _ = writeln!(
1549 out,
1550 " {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1551 );
1552 } else {
1553 let _ = writeln!(
1554 out,
1555 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1556 );
1557 }
1558 }
1559 }
1560 "contains_all" => {
1561 if let Some(values) = &assertion.values {
1562 for val in values {
1563 let gleam_val = json_to_gleam(val);
1564 if field_is_optional {
1565 let _ = writeln!(
1566 out,
1567 " {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1568 );
1569 } else {
1570 let _ = writeln!(
1571 out,
1572 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1573 );
1574 }
1575 }
1576 }
1577 }
1578 "not_contains" => {
1579 if let Some(expected) = &assertion.value {
1580 let gleam_val = json_to_gleam(expected);
1581 let _ = writeln!(
1582 out,
1583 " {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
1584 );
1585 }
1586 }
1587 "not_empty" => {
1588 if field_is_optional {
1589 let _ = writeln!(out, " {field_expr} |> option.is_some |> should.equal(True)");
1591 } else if field_is_array {
1592 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
1593 } else {
1594 let _ = writeln!(out, " {field_expr} |> string.is_empty |> should.equal(False)");
1595 }
1596 }
1597 "is_empty" => {
1598 if field_is_optional {
1599 let _ = writeln!(out, " {field_expr} |> option.is_none |> should.equal(True)");
1600 } else if field_is_array {
1601 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
1602 } else {
1603 let _ = writeln!(out, " {field_expr} |> string.is_empty |> should.equal(True)");
1604 }
1605 }
1606 "starts_with" => {
1607 if let Some(expected) = &assertion.value {
1608 let gleam_val = json_to_gleam(expected);
1609 let _ = writeln!(
1610 out,
1611 " {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
1612 );
1613 }
1614 }
1615 "ends_with" => {
1616 if let Some(expected) = &assertion.value {
1617 let gleam_val = json_to_gleam(expected);
1618 let _ = writeln!(
1619 out,
1620 " {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
1621 );
1622 }
1623 }
1624 "min_length" => {
1625 if let Some(val) = &assertion.value {
1626 if let Some(n) = val.as_u64() {
1627 let _ = writeln!(
1628 out,
1629 " {field_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1630 );
1631 }
1632 }
1633 }
1634 "max_length" => {
1635 if let Some(val) = &assertion.value {
1636 if let Some(n) = val.as_u64() {
1637 let _ = writeln!(
1638 out,
1639 " {field_expr} |> string.length |> fn(n__) {{ n__ <= {n} }} |> should.equal(True)"
1640 );
1641 }
1642 }
1643 }
1644 "count_min" => {
1645 if let Some(val) = &assertion.value {
1646 if let Some(n) = val.as_u64() {
1647 let _ = writeln!(
1648 out,
1649 " {field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1650 );
1651 }
1652 }
1653 }
1654 "count_equals" => {
1655 if let Some(val) = &assertion.value {
1656 if let Some(n) = val.as_u64() {
1657 let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
1658 }
1659 }
1660 }
1661 "is_true" => {
1662 let _ = writeln!(out, " {field_expr} |> should.equal(True)");
1663 }
1664 "is_false" => {
1665 let _ = writeln!(out, " {field_expr} |> should.equal(False)");
1666 }
1667 "not_error" => {
1668 }
1670 "error" => {
1671 }
1673 "greater_than" => {
1674 if let Some(val) = &assertion.value {
1675 let gleam_val = json_to_gleam(val);
1676 let _ = writeln!(
1677 out,
1678 " {field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1679 );
1680 }
1681 }
1682 "less_than" => {
1683 if let Some(val) = &assertion.value {
1684 let gleam_val = json_to_gleam(val);
1685 let _ = writeln!(
1686 out,
1687 " {field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1688 );
1689 }
1690 }
1691 "greater_than_or_equal" => {
1692 if let Some(val) = &assertion.value {
1693 let gleam_val = json_to_gleam(val);
1694 let _ = writeln!(
1695 out,
1696 " {field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1697 );
1698 }
1699 }
1700 "less_than_or_equal" => {
1701 if let Some(val) = &assertion.value {
1702 let gleam_val = json_to_gleam(val);
1703 let _ = writeln!(
1704 out,
1705 " {field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1706 );
1707 }
1708 }
1709 "contains_any" => {
1710 if let Some(values) = &assertion.values {
1711 let vals_list = values.iter().map(json_to_gleam).collect::<Vec<_>>().join(", ");
1712 let _ = writeln!(
1713 out,
1714 " [{vals_list}] |> list.any(fn(v__) {{ string.contains({field_expr}, v__) }}) |> should.equal(True)"
1715 );
1716 }
1717 }
1718 "matches_regex" => {
1719 let _ = writeln!(out, " // regex match not yet implemented for Gleam");
1720 }
1721 "method_result" => {
1722 let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
1723 }
1724 other => {
1725 panic!("Gleam e2e generator: unsupported assertion type: {other}");
1726 }
1727 }
1728}
1729
1730fn json_to_gleam(value: &serde_json::Value) -> String {
1732 match value {
1733 serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
1734 serde_json::Value::Bool(b) => {
1735 if *b {
1736 "True".to_string()
1737 } else {
1738 "False".to_string()
1739 }
1740 }
1741 serde_json::Value::Number(n) => n.to_string(),
1742 serde_json::Value::Null => "Nil".to_string(),
1743 serde_json::Value::Array(arr) => {
1744 let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
1745 format!("[{}]", items.join(", "))
1746 }
1747 serde_json::Value::Object(_) => {
1748 let json_str = serde_json::to_string(value).unwrap_or_default();
1749 format!("\"{}\"", escape_gleam(&json_str))
1750 }
1751 }
1752}
1753
1754#[cfg(test)]
1755mod tests {
1756 use super::*;
1757 use alef_core::config::{GleamElementConstructor, GleamElementField};
1758
1759 fn batch_file_item_recipe() -> GleamElementConstructor {
1760 GleamElementConstructor {
1761 element_type: "BatchFileItem".to_string(),
1762 constructor: "kreuzberg.BatchFileItem".to_string(),
1763 fields: vec![
1764 GleamElementField {
1765 gleam_field: "path".to_string(),
1766 kind: "file_path".to_string(),
1767 json_field: Some("path".to_string()),
1768 default: None,
1769 value: None,
1770 },
1771 GleamElementField {
1772 gleam_field: "config".to_string(),
1773 kind: "literal".to_string(),
1774 json_field: None,
1775 default: None,
1776 value: Some("option.None".to_string()),
1777 },
1778 ],
1779 }
1780 }
1781
1782 #[test]
1783 fn render_element_constructor_file_path_relative_path_gets_test_documents_prefix() {
1784 let item = serde_json::json!({ "path": "docx/fake.docx" });
1785 let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
1786 assert_eq!(
1787 out,
1788 "kreuzberg.BatchFileItem(path: \"../../test_documents/docx/fake.docx\", config: option.None)"
1789 );
1790 }
1791
1792 #[test]
1793 fn render_element_constructor_file_path_absolute_path_passes_through() {
1794 let item = serde_json::json!({ "path": "/etc/some/absolute" });
1795 let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
1796 assert!(
1797 out.contains("\"/etc/some/absolute\""),
1798 "absolute paths must NOT receive the test_documents prefix; got:\n{out}"
1799 );
1800 }
1801
1802 #[test]
1803 fn render_element_constructor_byte_array_emits_bitarray() {
1804 let recipe = GleamElementConstructor {
1805 element_type: "BatchBytesItem".to_string(),
1806 constructor: "kreuzberg.BatchBytesItem".to_string(),
1807 fields: vec![
1808 GleamElementField {
1809 gleam_field: "content".to_string(),
1810 kind: "byte_array".to_string(),
1811 json_field: Some("content".to_string()),
1812 default: None,
1813 value: None,
1814 },
1815 GleamElementField {
1816 gleam_field: "mime_type".to_string(),
1817 kind: "string".to_string(),
1818 json_field: Some("mime_type".to_string()),
1819 default: Some("text/plain".to_string()),
1820 value: None,
1821 },
1822 GleamElementField {
1823 gleam_field: "config".to_string(),
1824 kind: "literal".to_string(),
1825 json_field: None,
1826 default: None,
1827 value: Some("option.None".to_string()),
1828 },
1829 ],
1830 };
1831 let item = serde_json::json!({ "content": [72, 105], "mime_type": "text/html" });
1832 let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
1833 assert_eq!(
1834 out,
1835 "kreuzberg.BatchBytesItem(content: <<72, 105>>, mime_type: \"text/html\", config: option.None)"
1836 );
1837 }
1838
1839 #[test]
1840 fn build_args_with_json_object_wrapper_substitutes_placeholder() {
1841 use crate::config::ArgMapping;
1842 let arg = ArgMapping {
1843 name: "config".to_string(),
1844 field: "config".to_string(),
1845 arg_type: "json_object".to_string(),
1846 optional: false,
1847 owned: false,
1848 element_type: None,
1849 go_type: None,
1850 };
1851 let input = serde_json::json!({
1852 "config": { "use_cache": true, "force_ocr": false }
1853 });
1854 let (_setup, args_str) = build_args_and_setup(
1855 &input,
1856 &[arg],
1857 "test_fixture",
1858 "../../test_documents",
1859 &[],
1860 Some("k.config_from_json_string({json})"),
1861 "kreuzberg",
1862 );
1863 assert!(
1866 args_str.starts_with("k.config_from_json_string("),
1867 "wrapper must envelop the JSON literal; got:\n{args_str}"
1868 );
1869 assert!(
1870 args_str.contains("use_cache"),
1871 "JSON payload must reach the wrapper; got:\n{args_str}"
1872 );
1873 }
1874
1875 #[test]
1876 fn build_args_without_json_object_wrapper_emits_bare_json_string() {
1877 use crate::config::ArgMapping;
1878 let arg = ArgMapping {
1879 name: "config".to_string(),
1880 field: "config".to_string(),
1881 arg_type: "json_object".to_string(),
1882 optional: false,
1883 owned: false,
1884 element_type: None,
1885 go_type: None,
1886 };
1887 let input = serde_json::json!({ "config": { "x": 1 } });
1888 let (_setup, args_str) = build_args_and_setup(
1889 &input,
1890 &[arg],
1891 "test_fixture",
1892 "../../test_documents",
1893 &[],
1894 None,
1895 "kreuzberg",
1896 );
1897 assert!(
1900 !args_str.contains("from_json_string"),
1901 "no wrapper configured must not synthesise one; got:\n{args_str}"
1902 );
1903 assert!(
1904 args_str.starts_with('"'),
1905 "bare emission is a Gleam string literal starting with a quote; got:\n{args_str}"
1906 );
1907 }
1908
1909 #[test]
1910 fn render_element_constructor_string_falls_back_to_default() {
1911 let recipe = GleamElementConstructor {
1912 element_type: "BatchBytesItem".to_string(),
1913 constructor: "k.BatchBytesItem".to_string(),
1914 fields: vec![GleamElementField {
1915 gleam_field: "mime_type".to_string(),
1916 kind: "string".to_string(),
1917 json_field: Some("mime_type".to_string()),
1918 default: Some("text/plain".to_string()),
1919 value: None,
1920 }],
1921 };
1922 let item = serde_json::json!({});
1923 let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
1924 assert!(
1925 out.contains("mime_type: \"text/plain\""),
1926 "missing string field must fall back to default; got:\n{out}"
1927 );
1928 }
1929}