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 }
333 let _ = writeln!(out);
334
335 let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
337
338 for fixture in fixtures {
340 if fixture.is_http_test() {
341 continue; }
343 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
345 let has_bytes_arg = call_config.args.iter().any(|a| a.arg_type == "bytes");
346 let has_optional_string_arg = call_config.args.iter().any(|a| a.arg_type == "string" && a.optional);
348 let has_json_object_arg = call_config.args.iter().any(|a| a.arg_type == "json_object");
350 if has_bytes_arg || has_optional_string_arg || has_json_object_arg {
351 needed_modules.insert("option");
352 }
353 for assertion in &fixture.assertions {
354 let needs_case_expr = assertion
357 .field
358 .as_deref()
359 .is_some_and(|f| field_resolver.tagged_union_split(f).is_some());
360 if needs_case_expr {
361 needed_modules.insert("option");
362 }
363 if let Some(f) = &assertion.field {
365 if field_resolver.is_optional(f) {
366 needed_modules.insert("option");
367 }
368 }
369 match assertion.assertion_type.as_str() {
370 "contains_any" => {
371 needed_modules.insert("string");
373 needed_modules.insert("list");
374 }
375 "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" => {
376 needed_modules.insert("string");
377 if let Some(f) = &assertion.field {
379 let resolved = field_resolver.resolve(f);
380 if field_resolver.is_array(f) || field_resolver.is_array(resolved) {
381 needed_modules.insert("list");
382 }
383 } else {
384 if call_config.result_is_array
386 || call_config.result_is_vec
387 || field_resolver.is_array("")
388 || field_resolver.is_array(field_resolver.resolve(""))
389 {
390 needed_modules.insert("list");
391 }
392 }
393 }
394 "not_empty" | "is_empty" | "count_min" | "count_equals" => {
395 needed_modules.insert("list");
396 }
398 "min_length" | "max_length" => {
399 needed_modules.insert("string");
400 }
402 "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
403 }
405 _ => {}
406 }
407 if needs_case_expr {
409 if let Some(f) = &assertion.field {
410 let resolved = field_resolver.resolve(f);
411 if field_resolver.is_array(resolved) {
412 needed_modules.insert("list");
413 }
414 }
415 }
416 if let Some(f) = &assertion.field {
419 if !f.is_empty() {
420 let parts: Vec<&str> = f.split('.').collect();
421 let has_opt_prefix = (1..parts.len()).any(|i| {
422 let prefix_path = parts[..i].join(".");
423 field_resolver.is_optional(&prefix_path)
424 });
425 if has_opt_prefix {
426 needed_modules.insert("option");
427 }
428 }
429 }
430 }
431 }
432
433 for module in &needed_modules {
435 let _ = writeln!(out, "import gleam/{module}");
436 }
437
438 if !needed_modules.is_empty() {
439 let _ = writeln!(out);
440 }
441
442 for fixture in fixtures {
444 if fixture.is_http_test() {
445 render_http_test_case(&mut out, fixture);
446 } else {
447 render_test_case(
448 &mut out,
449 fixture,
450 e2e_config,
451 module_path,
452 function_name,
453 result_var,
454 args,
455 field_resolver,
456 enum_fields,
457 element_constructors,
458 json_object_wrapper,
459 );
460 }
461 let _ = writeln!(out);
462 }
463
464 out
465}
466
467struct GleamTestClientRenderer;
472
473impl client::TestClientRenderer for GleamTestClientRenderer {
474 fn language_name(&self) -> &'static str {
475 "gleam"
476 }
477
478 fn sanitize_test_name(&self, id: &str) -> String {
483 let raw = sanitize_ident(id);
484 let stripped = raw.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
485 if stripped.is_empty() { raw } else { stripped.to_string() }
486 }
487
488 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
494 let _ = writeln!(out, "// {description}");
495 let _ = writeln!(out, "pub fn {fn_name}_test() {{");
496 if let Some(reason) = skip_reason {
497 let escaped = escape_gleam(reason);
500 let _ = writeln!(out, " // skipped: {escaped}");
501 let _ = writeln!(out, " Nil");
502 }
503 }
504
505 fn render_test_close(&self, out: &mut String) {
507 let _ = writeln!(out, "}}");
508 }
509
510 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
516 let path = ctx.path;
517
518 let _ = writeln!(out, " let base_url = case envoy.get(\"MOCK_SERVER_URL\") {{");
520 let _ = writeln!(out, " Ok(u) -> u");
521 let _ = writeln!(out, " Error(_) -> \"http://localhost:8080\"");
522 let _ = writeln!(out, " }}");
523
524 let _ = writeln!(out, " let assert Ok(req) = request.to(base_url <> \"{path}\")");
526
527 let method_const = match ctx.method.to_uppercase().as_str() {
529 "GET" => "Get",
530 "POST" => "Post",
531 "PUT" => "Put",
532 "DELETE" => "Delete",
533 "PATCH" => "Patch",
534 "HEAD" => "Head",
535 "OPTIONS" => "Options",
536 _ => "Post",
537 };
538 let _ = writeln!(out, " let req = request.set_method(req, http.{method_const})");
539
540 if ctx.body.is_some() {
542 let content_type = ctx.content_type.unwrap_or("application/json");
543 let escaped_ct = escape_gleam(content_type);
544 let _ = writeln!(
545 out,
546 " let req = request.set_header(req, \"content-type\", \"{escaped_ct}\")"
547 );
548 }
549
550 for (name, value) in ctx.headers {
552 let lower = name.to_lowercase();
553 if matches!(lower.as_str(), "content-length" | "host" | "transfer-encoding") {
554 continue;
555 }
556 let escaped_name = escape_gleam(name);
557 let escaped_value = escape_gleam(value);
558 let _ = writeln!(
559 out,
560 " let req = request.set_header(req, \"{escaped_name}\", \"{escaped_value}\")"
561 );
562 }
563
564 if !ctx.cookies.is_empty() {
566 let cookie_str: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
567 let escaped_cookie = escape_gleam(&cookie_str.join("; "));
568 let _ = writeln!(
569 out,
570 " let req = request.set_header(req, \"cookie\", \"{escaped_cookie}\")"
571 );
572 }
573
574 if let Some(body) = ctx.body {
576 let json_str = serde_json::to_string(body).unwrap_or_default();
577 let escaped = escape_gleam(&json_str);
578 let _ = writeln!(out, " let req = request.set_body(req, \"{escaped}\")");
579 }
580
581 let resp = ctx.response_var;
583 let _ = writeln!(out, " let assert Ok({resp}) = httpc.send(req)");
584 }
585
586 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
588 let _ = writeln!(out, " {response_var}.status |> should.equal({status})");
589 }
590
591 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
597 let escaped_name = escape_gleam(&name.to_lowercase());
598 match expected {
599 "<<absent>>" => {
600 let _ = writeln!(
601 out,
602 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_false()"
603 );
604 }
605 "<<present>>" | "<<uuid>>" => {
606 let _ = writeln!(
608 out,
609 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_true()"
610 );
611 }
612 literal => {
613 let _escaped_value = escape_gleam(literal);
616 let _ = writeln!(
617 out,
618 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_true()"
619 );
620 }
621 }
622 }
623
624 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
630 let escaped = match expected {
631 serde_json::Value::String(s) => escape_gleam(s),
632 other => escape_gleam(&serde_json::to_string(other).unwrap_or_default()),
633 };
634 let _ = writeln!(
635 out,
636 " {response_var}.body |> string.trim |> should.equal(\"{escaped}\")"
637 );
638 }
639
640 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
646 if let Some(obj) = expected.as_object() {
647 for (key, val) in obj {
648 let fragment = escape_gleam(&format!("\"{}\":", key));
649 let _ = writeln!(
650 out,
651 " {response_var}.body |> string.contains(\"{fragment}\") |> should.equal(True)"
652 );
653 let _ = val; }
655 }
656 }
657
658 fn render_assert_validation_errors(
664 &self,
665 out: &mut String,
666 response_var: &str,
667 errors: &[ValidationErrorExpectation],
668 ) {
669 for err in errors {
670 let escaped_msg = escape_gleam(&err.msg);
671 let _ = writeln!(
672 out,
673 " {response_var}.body |> string.contains(\"{escaped_msg}\") |> should.equal(True)"
674 );
675 }
676 }
677}
678
679fn render_http_test_case(out: &mut String, fixture: &Fixture) {
685 client::http_call::render_http_test(out, &GleamTestClientRenderer, fixture);
686}
687
688#[allow(clippy::too_many_arguments)]
689fn render_test_case(
690 out: &mut String,
691 fixture: &Fixture,
692 e2e_config: &E2eConfig,
693 module_path: &str,
694 _function_name: &str,
695 _result_var: &str,
696 _args: &[crate::config::ArgMapping],
697 field_resolver: &FieldResolver,
698 enum_fields: &HashSet<String>,
699 element_constructors: &[alef_core::config::GleamElementConstructor],
700 json_object_wrapper: Option<&str>,
701) {
702 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
704 let lang = "gleam";
705 let call_overrides = call_config.overrides.get(lang);
706 let function_name = call_overrides
707 .and_then(|o| o.function.as_ref())
708 .cloned()
709 .unwrap_or_else(|| call_config.function.clone());
710 let result_var = &call_config.result_var;
711 let args = &call_config.args;
712
713 let raw_name = sanitize_ident(&fixture.id);
718 let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
719 let test_name = if stripped.is_empty() {
720 raw_name.as_str()
721 } else {
722 stripped
723 };
724 let description = &fixture.description;
725 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
726
727 let test_documents_path = e2e_config.test_documents_relative_from(0);
728 let (setup_lines, args_str) = build_args_and_setup(
729 &fixture.input,
730 args,
731 &fixture.id,
732 &test_documents_path,
733 element_constructors,
734 json_object_wrapper,
735 );
736
737 let _ = writeln!(out, "// {description}");
740 let _ = writeln!(out, "pub fn {test_name}_test() {{");
741
742 for line in &setup_lines {
743 let _ = writeln!(out, " {line}");
744 }
745
746 if expects_error {
747 let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
748 let _ = writeln!(out, "}}");
749 return;
750 }
751
752 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
753 let _ = writeln!(out, " {result_var} |> should.be_ok()");
754 let _ = writeln!(out, " let assert Ok(r) = {result_var}");
755
756 let result_is_array = call_config.result_is_array || call_config.result_is_vec;
757 let pkg_module = e2e_config
762 .resolve_package("gleam")
763 .as_ref()
764 .and_then(|p| p.name.as_ref())
765 .cloned()
766 .unwrap_or_else(|| module_path.split('.').next().unwrap_or(module_path).to_string());
767 for assertion in &fixture.assertions {
768 render_assertion(
769 out,
770 assertion,
771 "r",
772 field_resolver,
773 enum_fields,
774 result_is_array,
775 &pkg_module,
776 );
777 }
778
779 let _ = writeln!(out, "}}");
780}
781
782fn build_args_and_setup(
793 input: &serde_json::Value,
794 args: &[crate::config::ArgMapping],
795 _fixture_id: &str,
796 test_documents_path: &str,
797 element_constructors: &[alef_core::config::GleamElementConstructor],
798 json_object_wrapper: Option<&str>,
799) -> (Vec<String>, String) {
800 if args.is_empty() {
801 return (Vec::new(), String::new());
802 }
803
804 let mut setup_lines: Vec<String> = Vec::new();
805 let mut parts: Vec<String> = Vec::new();
806 let mut bytes_var_counter = 0usize;
807
808 for arg in args {
809 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
810 let val = input.get(field);
811
812 match arg.arg_type.as_str() {
813 "file_path" => {
814 let path = val.and_then(|v| v.as_str()).unwrap_or("");
818 let full_path = format!("{test_documents_path}/{path}");
819 parts.push(format!("\"{}\"", escape_gleam(&full_path)));
820 }
821 "bytes" => {
822 let path = val.and_then(|v| v.as_str()).unwrap_or("");
826 let var_name = if bytes_var_counter == 0 {
827 "data_bytes__".to_string()
828 } else {
829 format!("data_bytes_{bytes_var_counter}__")
830 };
831 bytes_var_counter += 1;
832 let full_path = format!("{test_documents_path}/{path}");
834 setup_lines.push(format!(
835 "let assert Ok({var_name}) = e2e_gleam.read_file_bytes(\"{}\")",
836 escape_gleam(&full_path)
837 ));
838 parts.push(var_name);
839 }
840 "string" if arg.optional => {
841 match val {
843 None | Some(serde_json::Value::Null) => {
844 parts.push("option.None".to_string());
845 }
846 Some(serde_json::Value::String(s)) if s.is_empty() => {
847 parts.push("option.None".to_string());
848 }
849 Some(serde_json::Value::String(s)) => {
850 parts.push(format!("option.Some(\"{}\")", escape_gleam(s)));
851 }
852 Some(v) => {
853 parts.push(format!("option.Some({})", json_to_gleam(v)));
854 }
855 }
856 }
857 "string" => {
858 match val {
860 None | Some(serde_json::Value::Null) => {
861 parts.push("\"\"".to_string());
862 }
863 Some(serde_json::Value::String(s)) => {
864 parts.push(format!("\"{}\"", escape_gleam(s)));
865 }
866 Some(v) => {
867 parts.push(json_to_gleam(v));
868 }
869 }
870 }
871 "json_object" => {
872 let element_type = arg.element_type.as_deref().unwrap_or("");
877 let recipe = if element_type.is_empty() {
878 None
879 } else {
880 element_constructors.iter().find(|r| r.element_type == element_type)
881 };
882
883 if let Some(recipe) = recipe {
884 let items_expr = match val {
888 Some(serde_json::Value::Array(arr)) => {
889 let items: Vec<String> = arr
890 .iter()
891 .map(|item| render_gleam_element_constructor(item, recipe, test_documents_path))
892 .collect();
893 format!("[{}]", items.join(", "))
894 }
895 _ => "[]".to_string(),
896 };
897 if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
898 parts.push("[]".to_string());
899 } else {
900 parts.push(items_expr);
901 }
902 } else if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
903 parts.push("option.None".to_string());
904 } else {
905 let empty_obj = serde_json::Value::Object(Default::default());
906 let config_val = val.unwrap_or(&empty_obj);
907 let json_literal = json_to_gleam(config_val);
908 let emitted = match json_object_wrapper {
913 Some(template) => template.replace("{json}", &json_literal),
914 None => json_literal,
915 };
916 parts.push(emitted);
917 }
918 }
919 "int" | "integer" => match val {
920 None | Some(serde_json::Value::Null) if arg.optional => {}
921 None | Some(serde_json::Value::Null) => parts.push("0".to_string()),
922 Some(v) => parts.push(json_to_gleam(v)),
923 },
924 "bool" | "boolean" => match val {
925 Some(serde_json::Value::Bool(true)) => parts.push("True".to_string()),
926 Some(serde_json::Value::Bool(false)) | None | Some(serde_json::Value::Null) => {
927 if !arg.optional {
928 parts.push("False".to_string());
929 }
930 }
931 Some(v) => parts.push(json_to_gleam(v)),
932 },
933 _ => {
934 match val {
936 None | Some(serde_json::Value::Null) if arg.optional => {}
937 None | Some(serde_json::Value::Null) => parts.push("Nil".to_string()),
938 Some(v) => parts.push(json_to_gleam(v)),
939 }
940 }
941 }
942 }
943
944 (setup_lines, parts.join(", "))
945}
946
947fn render_gleam_element_constructor(
959 item: &serde_json::Value,
960 recipe: &alef_core::config::GleamElementConstructor,
961 test_documents_path: &str,
962) -> String {
963 let mut field_exprs: Vec<String> = Vec::with_capacity(recipe.fields.len());
964 for field in &recipe.fields {
965 let expr = match field.kind.as_str() {
966 "file_path" => {
967 let json_field = field.json_field.as_deref().unwrap_or("");
968 let path = item.get(json_field).and_then(|v| v.as_str()).unwrap_or("");
969 let full = if path.starts_with('/') {
970 path.to_string()
971 } else {
972 format!("{test_documents_path}/{path}")
973 };
974 format!("\"{}\"", escape_gleam(&full))
975 }
976 "byte_array" => {
977 let json_field = field.json_field.as_deref().unwrap_or("");
978 let bytes: Vec<String> = item
979 .get(json_field)
980 .and_then(|v| v.as_array())
981 .map(|arr| arr.iter().map(|b| b.as_u64().unwrap_or(0).to_string()).collect())
982 .unwrap_or_default();
983 if bytes.is_empty() {
984 "<<>>".to_string()
985 } else {
986 format!("<<{}>>", bytes.join(", "))
987 }
988 }
989 "string" => {
990 let json_field = field.json_field.as_deref().unwrap_or("");
991 let value = item
992 .get(json_field)
993 .and_then(|v| v.as_str())
994 .map(str::to_string)
995 .or_else(|| field.default.clone())
996 .unwrap_or_default();
997 format!("\"{}\"", escape_gleam(&value))
998 }
999 "literal" => field.value.clone().unwrap_or_default(),
1000 other => {
1001 field
1006 .value
1007 .clone()
1008 .unwrap_or_else(|| format!("\"<unsupported kind: {other}>\""))
1009 }
1010 };
1011 field_exprs.push(format!("{}: {}", field.gleam_field, expr));
1012 }
1013 format!("{}({})", recipe.constructor, field_exprs.join(", "))
1014}
1015
1016fn render_tagged_union_assertion(
1025 out: &mut String,
1026 assertion: &Assertion,
1027 result_var: &str,
1028 prefix: &str,
1029 variant: &str,
1030 suffix: &str,
1031 field_resolver: &FieldResolver,
1032 pkg_module: &str,
1033) {
1034 let prefix_expr = if prefix.is_empty() {
1037 result_var.to_string()
1038 } else {
1039 format!("{result_var}.{prefix}")
1040 };
1041
1042 let constructor = variant.to_pascal_case();
1046 let module_qualifier = pkg_module;
1050
1051 let inner_var = "fmt_inner__";
1053
1054 let full_suffix_path = if prefix.is_empty() {
1057 format!("{variant}.{suffix}")
1058 } else {
1059 format!("{prefix}.{variant}.{suffix}")
1060 };
1061 let suffix_is_optional = field_resolver.is_optional(&full_suffix_path);
1062 let suffix_is_array = field_resolver.is_array(&full_suffix_path);
1063
1064 let _ = writeln!(out, " case {prefix_expr} {{");
1066 let _ = writeln!(
1067 out,
1068 " option.Some({module_qualifier}.{constructor}({inner_var})) -> {{"
1069 );
1070
1071 let inner_field_expr = if suffix.is_empty() {
1073 inner_var.to_string()
1074 } else {
1075 format!("{inner_var}.{suffix}")
1076 };
1077
1078 match assertion.assertion_type.as_str() {
1080 "equals" => {
1081 if let Some(expected) = &assertion.value {
1082 let gleam_val = json_to_gleam(expected);
1083 if suffix_is_optional {
1084 let default = default_gleam_value_for_optional(&gleam_val);
1085 let _ = writeln!(
1086 out,
1087 " {inner_field_expr} |> option.unwrap({default}) |> should.equal({gleam_val})"
1088 );
1089 } else {
1090 let _ = writeln!(out, " {inner_field_expr} |> should.equal({gleam_val})");
1091 }
1092 }
1093 }
1094 "contains" => {
1095 if let Some(expected) = &assertion.value {
1096 let gleam_val = json_to_gleam(expected);
1097 if suffix_is_array {
1098 let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
1100 let _ = writeln!(
1101 out,
1102 " items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1103 );
1104 } else if suffix_is_optional {
1105 let _ = writeln!(
1106 out,
1107 " {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1108 );
1109 } else {
1110 let _ = writeln!(
1111 out,
1112 " {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1113 );
1114 }
1115 }
1116 }
1117 "contains_all" => {
1118 if let Some(values) = &assertion.values {
1119 if suffix_is_array {
1120 let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
1122 for val in values {
1123 let gleam_val = json_to_gleam(val);
1124 let _ = writeln!(
1125 out,
1126 " items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1127 );
1128 }
1129 } else if suffix_is_optional {
1130 for val in values {
1131 let gleam_val = json_to_gleam(val);
1132 let _ = writeln!(
1133 out,
1134 " {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1135 );
1136 }
1137 } else {
1138 for val in values {
1139 let gleam_val = json_to_gleam(val);
1140 let _ = writeln!(
1141 out,
1142 " {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1143 );
1144 }
1145 }
1146 }
1147 }
1148 "greater_than_or_equal" => {
1149 if let Some(val) = &assertion.value {
1150 let gleam_val = json_to_gleam(val);
1151 if suffix_is_optional {
1152 let _ = writeln!(
1153 out,
1154 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1155 );
1156 } else {
1157 let _ = writeln!(
1158 out,
1159 " {inner_field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1160 );
1161 }
1162 }
1163 }
1164 "greater_than" => {
1165 if let Some(val) = &assertion.value {
1166 let gleam_val = json_to_gleam(val);
1167 if suffix_is_optional {
1168 let _ = writeln!(
1169 out,
1170 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1171 );
1172 } else {
1173 let _ = writeln!(
1174 out,
1175 " {inner_field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1176 );
1177 }
1178 }
1179 }
1180 "less_than" => {
1181 if let Some(val) = &assertion.value {
1182 let gleam_val = json_to_gleam(val);
1183 if suffix_is_optional {
1184 let _ = writeln!(
1185 out,
1186 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1187 );
1188 } else {
1189 let _ = writeln!(
1190 out,
1191 " {inner_field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1192 );
1193 }
1194 }
1195 }
1196 "less_than_or_equal" => {
1197 if let Some(val) = &assertion.value {
1198 let gleam_val = json_to_gleam(val);
1199 if suffix_is_optional {
1200 let _ = writeln!(
1201 out,
1202 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1203 );
1204 } else {
1205 let _ = writeln!(
1206 out,
1207 " {inner_field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1208 );
1209 }
1210 }
1211 }
1212 "count_min" => {
1213 if let Some(val) = &assertion.value {
1214 if let Some(n) = val.as_u64() {
1215 if suffix_is_optional {
1216 let _ = writeln!(
1217 out,
1218 " {inner_field_expr} |> option.unwrap([]) |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1219 );
1220 } else {
1221 let _ = writeln!(
1222 out,
1223 " {inner_field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1224 );
1225 }
1226 }
1227 }
1228 }
1229 "count_equals" => {
1230 if let Some(val) = &assertion.value {
1231 if let Some(n) = val.as_u64() {
1232 if suffix_is_optional {
1233 let _ = writeln!(
1234 out,
1235 " {inner_field_expr} |> option.unwrap([]) |> list.length |> should.equal({n})"
1236 );
1237 } else {
1238 let _ = writeln!(out, " {inner_field_expr} |> list.length |> should.equal({n})");
1239 }
1240 }
1241 }
1242 }
1243 "not_empty" => {
1244 if suffix_is_optional {
1245 let _ = writeln!(
1246 out,
1247 " {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(False)"
1248 );
1249 } else {
1250 let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(False)");
1251 }
1252 }
1253 "is_empty" => {
1254 if suffix_is_optional {
1255 let _ = writeln!(
1256 out,
1257 " {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(True)"
1258 );
1259 } else {
1260 let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(True)");
1261 }
1262 }
1263 "is_true" => {
1264 let _ = writeln!(out, " {inner_field_expr} |> should.equal(True)");
1265 }
1266 "is_false" => {
1267 let _ = writeln!(out, " {inner_field_expr} |> should.equal(False)");
1268 }
1269 other => {
1270 let _ = writeln!(
1271 out,
1272 " // tagged-union assertion '{other}' not yet implemented for Gleam"
1273 );
1274 }
1275 }
1276
1277 let _ = writeln!(out, " }}");
1279 let _ = writeln!(
1280 out,
1281 " _ -> panic as \"expected {module_qualifier}.{constructor} format metadata\""
1282 );
1283 let _ = writeln!(out, " }}");
1284}
1285
1286fn default_gleam_value_for_optional(gleam_val: &str) -> &'static str {
1289 if gleam_val.starts_with('"') {
1290 "\"\""
1291 } else if gleam_val == "True" || gleam_val == "False" {
1292 "False"
1293 } else if gleam_val.contains('.') {
1294 "0.0"
1295 } else {
1296 "0"
1297 }
1298}
1299
1300fn render_assertion(
1301 out: &mut String,
1302 assertion: &Assertion,
1303 result_var: &str,
1304 field_resolver: &FieldResolver,
1305 enum_fields: &HashSet<String>,
1306 result_is_array: bool,
1307 pkg_module: &str,
1308) {
1309 if let Some(f) = &assertion.field {
1311 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1312 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1313 return;
1314 }
1315 }
1316
1317 if let Some(f) = &assertion.field {
1321 if !f.is_empty() {
1322 if let Some((prefix, variant, suffix)) = field_resolver.tagged_union_split(f) {
1323 render_tagged_union_assertion(
1324 out,
1325 assertion,
1326 result_var,
1327 &prefix,
1328 &variant,
1329 &suffix,
1330 field_resolver,
1331 pkg_module,
1332 );
1333 return;
1334 }
1335 }
1336 }
1337
1338 if let Some(f) = &assertion.field {
1341 if !f.is_empty() {
1342 let parts: Vec<&str> = f.split('.').collect();
1343 let mut opt_prefix: Option<(String, usize)> = None;
1344 for i in 1..parts.len() {
1345 let prefix_path = parts[..i].join(".");
1346 if field_resolver.is_optional(&prefix_path) {
1347 opt_prefix = Some((prefix_path, i));
1348 break;
1349 }
1350 }
1351 if let Some((optional_prefix, suffix_start)) = opt_prefix {
1352 let prefix_expr = format!("{result_var}.{optional_prefix}");
1353 let suffix_parts = &parts[suffix_start..];
1354 let suffix_str = suffix_parts.join(".");
1355 let inner_var = "opt_inner__";
1356 let inner_expr = if suffix_str.is_empty() {
1357 inner_var.to_string()
1358 } else {
1359 format!("{inner_var}.{suffix_str}")
1360 };
1361 let _ = writeln!(out, " case {prefix_expr} {{");
1362 let _ = writeln!(out, " option.Some({inner_var}) -> {{");
1363 match assertion.assertion_type.as_str() {
1364 "count_min" => {
1365 if let Some(val) = &assertion.value {
1366 if let Some(n) = val.as_u64() {
1367 let _ = writeln!(
1368 out,
1369 " {inner_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1370 );
1371 }
1372 }
1373 }
1374 "count_equals" => {
1375 if let Some(val) = &assertion.value {
1376 if let Some(n) = val.as_u64() {
1377 let _ = writeln!(out, " {inner_expr} |> list.length |> should.equal({n})");
1378 }
1379 }
1380 }
1381 "not_empty" => {
1382 let _ = writeln!(out, " {inner_expr} |> list.is_empty |> should.equal(False)");
1383 }
1384 "min_length" => {
1385 if let Some(val) = &assertion.value {
1386 if let Some(n) = val.as_u64() {
1387 let _ = writeln!(
1388 out,
1389 " {inner_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1390 );
1391 }
1392 }
1393 }
1394 other => {
1395 let _ = writeln!(
1396 out,
1397 " // optional-prefix assertion '{other}' not yet implemented for Gleam"
1398 );
1399 }
1400 }
1401 let _ = writeln!(out, " }}");
1402 let _ = writeln!(out, " option.None -> should.fail()");
1403 let _ = writeln!(out, " }}");
1404 return;
1405 }
1406 }
1407 }
1408
1409 let field_is_optional = assertion
1412 .field
1413 .as_deref()
1414 .is_some_and(|f| !f.is_empty() && field_resolver.is_optional(f));
1415
1416 let _field_is_enum = assertion
1418 .field
1419 .as_deref()
1420 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1421
1422 let field_expr = match &assertion.field {
1423 Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
1424 _ => result_var.to_string(),
1425 };
1426
1427 let field_is_array = {
1430 let f = assertion.field.as_deref().unwrap_or("");
1431 let is_root = f.is_empty();
1432 (is_root && result_is_array) || field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f))
1433 };
1434
1435 match assertion.assertion_type.as_str() {
1436 "equals" => {
1437 if let Some(expected) = &assertion.value {
1438 let gleam_val = json_to_gleam(expected);
1439 if field_is_optional {
1440 let _ = writeln!(out, " {field_expr} |> should.equal(option.Some({gleam_val}))");
1442 } else {
1443 let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
1444 }
1445 }
1446 }
1447 "contains" => {
1448 if let Some(expected) = &assertion.value {
1449 let gleam_val = json_to_gleam(expected);
1450 if field_is_array {
1451 let _ = writeln!(
1453 out,
1454 " {field_expr} |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1455 );
1456 } else if field_is_optional {
1457 let _ = writeln!(
1458 out,
1459 " {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1460 );
1461 } else {
1462 let _ = writeln!(
1463 out,
1464 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1465 );
1466 }
1467 }
1468 }
1469 "contains_all" => {
1470 if let Some(values) = &assertion.values {
1471 for val in values {
1472 let gleam_val = json_to_gleam(val);
1473 if field_is_optional {
1474 let _ = writeln!(
1475 out,
1476 " {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1477 );
1478 } else {
1479 let _ = writeln!(
1480 out,
1481 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1482 );
1483 }
1484 }
1485 }
1486 }
1487 "not_contains" => {
1488 if let Some(expected) = &assertion.value {
1489 let gleam_val = json_to_gleam(expected);
1490 let _ = writeln!(
1491 out,
1492 " {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
1493 );
1494 }
1495 }
1496 "not_empty" => {
1497 if field_is_optional {
1498 let _ = writeln!(out, " {field_expr} |> option.is_some |> should.equal(True)");
1500 } else {
1501 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
1502 }
1503 }
1504 "is_empty" => {
1505 if field_is_optional {
1506 let _ = writeln!(out, " {field_expr} |> option.is_none |> should.equal(True)");
1507 } else {
1508 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
1509 }
1510 }
1511 "starts_with" => {
1512 if let Some(expected) = &assertion.value {
1513 let gleam_val = json_to_gleam(expected);
1514 let _ = writeln!(
1515 out,
1516 " {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
1517 );
1518 }
1519 }
1520 "ends_with" => {
1521 if let Some(expected) = &assertion.value {
1522 let gleam_val = json_to_gleam(expected);
1523 let _ = writeln!(
1524 out,
1525 " {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
1526 );
1527 }
1528 }
1529 "min_length" => {
1530 if let Some(val) = &assertion.value {
1531 if let Some(n) = val.as_u64() {
1532 let _ = writeln!(
1533 out,
1534 " {field_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1535 );
1536 }
1537 }
1538 }
1539 "max_length" => {
1540 if let Some(val) = &assertion.value {
1541 if let Some(n) = val.as_u64() {
1542 let _ = writeln!(
1543 out,
1544 " {field_expr} |> string.length |> fn(n__) {{ n__ <= {n} }} |> should.equal(True)"
1545 );
1546 }
1547 }
1548 }
1549 "count_min" => {
1550 if let Some(val) = &assertion.value {
1551 if let Some(n) = val.as_u64() {
1552 let _ = writeln!(
1553 out,
1554 " {field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1555 );
1556 }
1557 }
1558 }
1559 "count_equals" => {
1560 if let Some(val) = &assertion.value {
1561 if let Some(n) = val.as_u64() {
1562 let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
1563 }
1564 }
1565 }
1566 "is_true" => {
1567 let _ = writeln!(out, " {field_expr} |> should.equal(True)");
1568 }
1569 "is_false" => {
1570 let _ = writeln!(out, " {field_expr} |> should.equal(False)");
1571 }
1572 "not_error" => {
1573 }
1575 "error" => {
1576 }
1578 "greater_than" => {
1579 if let Some(val) = &assertion.value {
1580 let gleam_val = json_to_gleam(val);
1581 let _ = writeln!(
1582 out,
1583 " {field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1584 );
1585 }
1586 }
1587 "less_than" => {
1588 if let Some(val) = &assertion.value {
1589 let gleam_val = json_to_gleam(val);
1590 let _ = writeln!(
1591 out,
1592 " {field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1593 );
1594 }
1595 }
1596 "greater_than_or_equal" => {
1597 if let Some(val) = &assertion.value {
1598 let gleam_val = json_to_gleam(val);
1599 let _ = writeln!(
1600 out,
1601 " {field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1602 );
1603 }
1604 }
1605 "less_than_or_equal" => {
1606 if let Some(val) = &assertion.value {
1607 let gleam_val = json_to_gleam(val);
1608 let _ = writeln!(
1609 out,
1610 " {field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1611 );
1612 }
1613 }
1614 "contains_any" => {
1615 if let Some(values) = &assertion.values {
1616 let vals_list = values.iter().map(json_to_gleam).collect::<Vec<_>>().join(", ");
1617 let _ = writeln!(
1618 out,
1619 " [{vals_list}] |> list.any(fn(v__) {{ string.contains({field_expr}, v__) }}) |> should.equal(True)"
1620 );
1621 }
1622 }
1623 "matches_regex" => {
1624 let _ = writeln!(out, " // regex match not yet implemented for Gleam");
1625 }
1626 "method_result" => {
1627 let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
1628 }
1629 other => {
1630 panic!("Gleam e2e generator: unsupported assertion type: {other}");
1631 }
1632 }
1633}
1634
1635fn json_to_gleam(value: &serde_json::Value) -> String {
1637 match value {
1638 serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
1639 serde_json::Value::Bool(b) => {
1640 if *b {
1641 "True".to_string()
1642 } else {
1643 "False".to_string()
1644 }
1645 }
1646 serde_json::Value::Number(n) => n.to_string(),
1647 serde_json::Value::Null => "Nil".to_string(),
1648 serde_json::Value::Array(arr) => {
1649 let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
1650 format!("[{}]", items.join(", "))
1651 }
1652 serde_json::Value::Object(_) => {
1653 let json_str = serde_json::to_string(value).unwrap_or_default();
1654 format!("\"{}\"", escape_gleam(&json_str))
1655 }
1656 }
1657}
1658
1659#[cfg(test)]
1660mod tests {
1661 use super::*;
1662 use alef_core::config::{GleamElementConstructor, GleamElementField};
1663
1664 fn batch_file_item_recipe() -> GleamElementConstructor {
1665 GleamElementConstructor {
1666 element_type: "BatchFileItem".to_string(),
1667 constructor: "kreuzberg.BatchFileItem".to_string(),
1668 fields: vec![
1669 GleamElementField {
1670 gleam_field: "path".to_string(),
1671 kind: "file_path".to_string(),
1672 json_field: Some("path".to_string()),
1673 default: None,
1674 value: None,
1675 },
1676 GleamElementField {
1677 gleam_field: "config".to_string(),
1678 kind: "literal".to_string(),
1679 json_field: None,
1680 default: None,
1681 value: Some("option.None".to_string()),
1682 },
1683 ],
1684 }
1685 }
1686
1687 #[test]
1688 fn render_element_constructor_file_path_relative_path_gets_test_documents_prefix() {
1689 let item = serde_json::json!({ "path": "docx/fake.docx" });
1690 let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
1691 assert_eq!(
1692 out,
1693 "kreuzberg.BatchFileItem(path: \"../../test_documents/docx/fake.docx\", config: option.None)"
1694 );
1695 }
1696
1697 #[test]
1698 fn render_element_constructor_file_path_absolute_path_passes_through() {
1699 let item = serde_json::json!({ "path": "/etc/some/absolute" });
1700 let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
1701 assert!(
1702 out.contains("\"/etc/some/absolute\""),
1703 "absolute paths must NOT receive the test_documents prefix; got:\n{out}"
1704 );
1705 }
1706
1707 #[test]
1708 fn render_element_constructor_byte_array_emits_bitarray() {
1709 let recipe = GleamElementConstructor {
1710 element_type: "BatchBytesItem".to_string(),
1711 constructor: "kreuzberg.BatchBytesItem".to_string(),
1712 fields: vec![
1713 GleamElementField {
1714 gleam_field: "content".to_string(),
1715 kind: "byte_array".to_string(),
1716 json_field: Some("content".to_string()),
1717 default: None,
1718 value: None,
1719 },
1720 GleamElementField {
1721 gleam_field: "mime_type".to_string(),
1722 kind: "string".to_string(),
1723 json_field: Some("mime_type".to_string()),
1724 default: Some("text/plain".to_string()),
1725 value: None,
1726 },
1727 GleamElementField {
1728 gleam_field: "config".to_string(),
1729 kind: "literal".to_string(),
1730 json_field: None,
1731 default: None,
1732 value: Some("option.None".to_string()),
1733 },
1734 ],
1735 };
1736 let item = serde_json::json!({ "content": [72, 105], "mime_type": "text/html" });
1737 let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
1738 assert_eq!(
1739 out,
1740 "kreuzberg.BatchBytesItem(content: <<72, 105>>, mime_type: \"text/html\", config: option.None)"
1741 );
1742 }
1743
1744 #[test]
1745 fn build_args_with_json_object_wrapper_substitutes_placeholder() {
1746 use crate::config::ArgMapping;
1747 let arg = ArgMapping {
1748 name: "config".to_string(),
1749 field: "config".to_string(),
1750 arg_type: "json_object".to_string(),
1751 optional: false,
1752 owned: false,
1753 element_type: None,
1754 go_type: None,
1755 };
1756 let input = serde_json::json!({
1757 "config": { "use_cache": true, "force_ocr": false }
1758 });
1759 let (_setup, args_str) = build_args_and_setup(
1760 &input,
1761 &[arg],
1762 "test_fixture",
1763 "../../test_documents",
1764 &[],
1765 Some("k.config_from_json_string({json})"),
1766 );
1767 assert!(
1770 args_str.starts_with("k.config_from_json_string("),
1771 "wrapper must envelop the JSON literal; got:\n{args_str}"
1772 );
1773 assert!(
1774 args_str.contains("use_cache"),
1775 "JSON payload must reach the wrapper; got:\n{args_str}"
1776 );
1777 }
1778
1779 #[test]
1780 fn build_args_without_json_object_wrapper_emits_bare_json_string() {
1781 use crate::config::ArgMapping;
1782 let arg = ArgMapping {
1783 name: "config".to_string(),
1784 field: "config".to_string(),
1785 arg_type: "json_object".to_string(),
1786 optional: false,
1787 owned: false,
1788 element_type: None,
1789 go_type: None,
1790 };
1791 let input = serde_json::json!({ "config": { "x": 1 } });
1792 let (_setup, args_str) =
1793 build_args_and_setup(&input, &[arg], "test_fixture", "../../test_documents", &[], None);
1794 assert!(
1797 !args_str.contains("from_json_string"),
1798 "no wrapper configured must not synthesise one; got:\n{args_str}"
1799 );
1800 assert!(
1801 args_str.starts_with('"'),
1802 "bare emission is a Gleam string literal starting with a quote; got:\n{args_str}"
1803 );
1804 }
1805
1806 #[test]
1807 fn render_element_constructor_string_falls_back_to_default() {
1808 let recipe = GleamElementConstructor {
1809 element_type: "BatchBytesItem".to_string(),
1810 constructor: "k.BatchBytesItem".to_string(),
1811 fields: vec![GleamElementField {
1812 gleam_field: "mime_type".to_string(),
1813 kind: "string".to_string(),
1814 json_field: Some("mime_type".to_string()),
1815 default: Some("text/plain".to_string()),
1816 value: None,
1817 }],
1818 };
1819 let item = serde_json::json!({});
1820 let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
1821 assert!(
1822 out.contains("mime_type: \"text/plain\""),
1823 "missing string field must fall back to default; got:\n{out}"
1824 );
1825 }
1826}