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