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> = config
180 .gleam
181 .as_ref()
182 .and_then(|g| g.json_object_wrapper.as_deref());
183 let content = render_test_file(
184 &group.category,
185 &active,
186 e2e_config,
187 &module_path,
188 &function_name,
189 result_var,
190 &e2e_config.call.args,
191 &field_resolver,
192 &e2e_config.fields_enum,
193 element_constructors,
194 json_object_wrapper,
195 );
196 files.push(GeneratedFile {
197 path: output_base.join("test").join(filename),
198 content,
199 generated_header: true,
200 });
201 any_tests = true;
202 }
203
204 let entry = if any_tests {
209 concat!(
210 "// Generated by alef. Do not edit by hand.\n",
211 "import gleeunit\n",
212 "import e2e_gleam\n",
213 "\n",
214 "pub fn main() {\n",
215 " let _ = e2e_gleam.start_app()\n",
216 " gleeunit.main()\n",
217 "}\n",
218 )
219 .to_string()
220 } else {
221 concat!(
222 "// Generated by alef. Do not edit by hand.\n",
223 "// No fixture-driven tests for Gleam — e2e tests require HTTP fixtures\n",
224 "// or non-HTTP fixtures with gleam-specific call overrides.\n",
225 "import gleeunit\n",
226 "import gleeunit/should\n",
227 "\n",
228 "pub fn main() {\n",
229 " gleeunit.main()\n",
230 "}\n",
231 "\n",
232 "pub fn compilation_smoke_test() {\n",
233 " True |> should.equal(True)\n",
234 "}\n",
235 )
236 .to_string()
237 };
238 files.push(GeneratedFile {
239 path: output_base.join("test").join("e2e_gleam_test.gleam"),
240 content: entry,
241 generated_header: false,
242 });
243
244 Ok(files)
245 }
246
247 fn language_name(&self) -> &'static str {
248 "gleam"
249 }
250}
251
252fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
257 use alef_core::template_versions::hex;
258 let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
259 let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
260 let gleam_httpc = hex::GLEAM_HTTPC_VERSION_RANGE;
261 let envoy = hex::ENVOY_VERSION_RANGE;
262 let deps = match dep_mode {
263 crate::config::DependencyMode::Registry => {
264 format!(
265 r#"{pkg_name} = ">= 0.1.0"
266gleam_stdlib = "{stdlib}"
267gleeunit = "{gleeunit}"
268gleam_httpc = "{gleam_httpc}"
269gleam_http = ">= 4.0.0 and < 5.0.0"
270envoy = "{envoy}""#
271 )
272 }
273 crate::config::DependencyMode::Local => {
274 format!(
275 r#"{pkg_name} = {{ path = "{pkg_path}" }}
276gleam_stdlib = "{stdlib}"
277gleeunit = "{gleeunit}"
278gleam_httpc = "{gleam_httpc}"
279gleam_http = ">= 4.0.0 and < 5.0.0"
280envoy = "{envoy}""#
281 )
282 }
283 };
284
285 format!(
286 r#"name = "e2e_gleam"
287version = "0.1.0"
288target = "erlang"
289
290[dependencies]
291{deps}
292"#
293 )
294}
295
296#[allow(clippy::too_many_arguments)]
297fn render_test_file(
298 _category: &str,
299 fixtures: &[&Fixture],
300 e2e_config: &E2eConfig,
301 module_path: &str,
302 function_name: &str,
303 result_var: &str,
304 args: &[crate::config::ArgMapping],
305 field_resolver: &FieldResolver,
306 enum_fields: &HashSet<String>,
307 element_constructors: &[alef_core::config::GleamElementConstructor],
308 json_object_wrapper: Option<&str>,
309) -> String {
310 let mut out = String::new();
311 out.push_str(&hash::header(CommentStyle::DoubleSlash));
312 let _ = writeln!(out, "import gleeunit");
313 let _ = writeln!(out, "import gleeunit/should");
314
315 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
317
318 if has_http_fixtures {
320 let _ = writeln!(out, "import gleam/httpc");
321 let _ = writeln!(out, "import gleam/http");
322 let _ = writeln!(out, "import gleam/http/request");
323 let _ = writeln!(out, "import gleam/list");
324 let _ = writeln!(out, "import gleam/result");
325 let _ = writeln!(out, "import gleam/string");
326 let _ = writeln!(out, "import envoy");
327 }
328
329 let has_non_http_with_override = fixtures.iter().any(|f| !f.is_http_test());
331 if has_non_http_with_override {
332 let _ = writeln!(out, "import {module_path}");
333 let _ = writeln!(out, "import e2e_gleam");
334 }
335 let _ = writeln!(out);
336
337 let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
339
340 for fixture in fixtures {
342 if fixture.is_http_test() {
343 continue; }
345 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
347 let has_bytes_arg = call_config.args.iter().any(|a| a.arg_type == "bytes");
348 let has_optional_string_arg = call_config.args.iter().any(|a| a.arg_type == "string" && a.optional);
350 let has_json_object_arg = call_config.args.iter().any(|a| a.arg_type == "json_object");
352 if has_bytes_arg || has_optional_string_arg || has_json_object_arg {
353 needed_modules.insert("option");
354 }
355 for assertion in &fixture.assertions {
356 let needs_case_expr = assertion
359 .field
360 .as_deref()
361 .is_some_and(|f| field_resolver.tagged_union_split(f).is_some());
362 if needs_case_expr {
363 needed_modules.insert("option");
364 }
365 if let Some(f) = &assertion.field {
367 if field_resolver.is_optional(f) {
368 needed_modules.insert("option");
369 }
370 }
371 match assertion.assertion_type.as_str() {
372 "contains_any" => {
373 needed_modules.insert("string");
375 needed_modules.insert("list");
376 }
377 "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" => {
378 needed_modules.insert("string");
379 if let Some(f) = &assertion.field {
381 let resolved = field_resolver.resolve(f);
382 if field_resolver.is_array(f) || field_resolver.is_array(resolved) {
383 needed_modules.insert("list");
384 }
385 } else {
386 if call_config.result_is_array
388 || call_config.result_is_vec
389 || field_resolver.is_array("")
390 || field_resolver.is_array(field_resolver.resolve(""))
391 {
392 needed_modules.insert("list");
393 }
394 }
395 }
396 "not_empty" | "is_empty" | "count_min" | "count_equals" => {
397 needed_modules.insert("list");
398 }
400 "min_length" | "max_length" => {
401 needed_modules.insert("string");
402 }
404 "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
405 }
407 _ => {}
408 }
409 if needs_case_expr {
411 if let Some(f) = &assertion.field {
412 let resolved = field_resolver.resolve(f);
413 if field_resolver.is_array(resolved) {
414 needed_modules.insert("list");
415 }
416 }
417 }
418 if let Some(f) = &assertion.field {
421 if !f.is_empty() {
422 let parts: Vec<&str> = f.split('.').collect();
423 let has_opt_prefix = (1..parts.len()).any(|i| {
424 let prefix_path = parts[..i].join(".");
425 field_resolver.is_optional(&prefix_path)
426 });
427 if has_opt_prefix {
428 needed_modules.insert("option");
429 }
430 }
431 }
432 }
433 }
434
435 for module in &needed_modules {
437 let _ = writeln!(out, "import gleam/{module}");
438 }
439
440 if !needed_modules.is_empty() {
441 let _ = writeln!(out);
442 }
443
444 for fixture in fixtures {
446 if fixture.is_http_test() {
447 render_http_test_case(&mut out, fixture);
448 } else {
449 render_test_case(
450 &mut out,
451 fixture,
452 e2e_config,
453 module_path,
454 function_name,
455 result_var,
456 args,
457 field_resolver,
458 enum_fields,
459 element_constructors,
460 json_object_wrapper,
461 );
462 }
463 let _ = writeln!(out);
464 }
465
466 out
467}
468
469struct GleamTestClientRenderer;
474
475impl client::TestClientRenderer for GleamTestClientRenderer {
476 fn language_name(&self) -> &'static str {
477 "gleam"
478 }
479
480 fn sanitize_test_name(&self, id: &str) -> String {
485 let raw = sanitize_ident(id);
486 let stripped = raw.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
487 if stripped.is_empty() { raw } else { stripped.to_string() }
488 }
489
490 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
496 let _ = writeln!(out, "// {description}");
497 let _ = writeln!(out, "pub fn {fn_name}_test() {{");
498 if let Some(reason) = skip_reason {
499 let escaped = escape_gleam(reason);
502 let _ = writeln!(out, " // skipped: {escaped}");
503 let _ = writeln!(out, " Nil");
504 }
505 }
506
507 fn render_test_close(&self, out: &mut String) {
509 let _ = writeln!(out, "}}");
510 }
511
512 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
518 let path = ctx.path;
519
520 let _ = writeln!(out, " let base_url = case envoy.get(\"MOCK_SERVER_URL\") {{");
522 let _ = writeln!(out, " Ok(u) -> u");
523 let _ = writeln!(out, " Error(_) -> \"http://localhost:8080\"");
524 let _ = writeln!(out, " }}");
525
526 let _ = writeln!(out, " let assert Ok(req) = request.to(base_url <> \"{path}\")");
528
529 let method_const = match ctx.method.to_uppercase().as_str() {
531 "GET" => "Get",
532 "POST" => "Post",
533 "PUT" => "Put",
534 "DELETE" => "Delete",
535 "PATCH" => "Patch",
536 "HEAD" => "Head",
537 "OPTIONS" => "Options",
538 _ => "Post",
539 };
540 let _ = writeln!(out, " let req = request.set_method(req, http.{method_const})");
541
542 if ctx.body.is_some() {
544 let content_type = ctx.content_type.unwrap_or("application/json");
545 let escaped_ct = escape_gleam(content_type);
546 let _ = writeln!(
547 out,
548 " let req = request.set_header(req, \"content-type\", \"{escaped_ct}\")"
549 );
550 }
551
552 for (name, value) in ctx.headers {
554 let lower = name.to_lowercase();
555 if matches!(lower.as_str(), "content-length" | "host" | "transfer-encoding") {
556 continue;
557 }
558 let escaped_name = escape_gleam(name);
559 let escaped_value = escape_gleam(value);
560 let _ = writeln!(
561 out,
562 " let req = request.set_header(req, \"{escaped_name}\", \"{escaped_value}\")"
563 );
564 }
565
566 if !ctx.cookies.is_empty() {
568 let cookie_str: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
569 let escaped_cookie = escape_gleam(&cookie_str.join("; "));
570 let _ = writeln!(
571 out,
572 " let req = request.set_header(req, \"cookie\", \"{escaped_cookie}\")"
573 );
574 }
575
576 if let Some(body) = ctx.body {
578 let json_str = serde_json::to_string(body).unwrap_or_default();
579 let escaped = escape_gleam(&json_str);
580 let _ = writeln!(out, " let req = request.set_body(req, \"{escaped}\")");
581 }
582
583 let resp = ctx.response_var;
585 let _ = writeln!(out, " let assert Ok({resp}) = httpc.send(req)");
586 }
587
588 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
590 let _ = writeln!(out, " {response_var}.status |> should.equal({status})");
591 }
592
593 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
599 let escaped_name = escape_gleam(&name.to_lowercase());
600 match expected {
601 "<<absent>>" => {
602 let _ = writeln!(
603 out,
604 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_false()"
605 );
606 }
607 "<<present>>" | "<<uuid>>" => {
608 let _ = writeln!(
610 out,
611 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_true()"
612 );
613 }
614 literal => {
615 let _escaped_value = escape_gleam(literal);
618 let _ = writeln!(
619 out,
620 " {response_var}.headers\n |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})\n |> result.is_ok()\n |> should.be_true()"
621 );
622 }
623 }
624 }
625
626 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
632 let escaped = match expected {
633 serde_json::Value::String(s) => escape_gleam(s),
634 other => escape_gleam(&serde_json::to_string(other).unwrap_or_default()),
635 };
636 let _ = writeln!(
637 out,
638 " {response_var}.body |> string.trim |> should.equal(\"{escaped}\")"
639 );
640 }
641
642 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
648 if let Some(obj) = expected.as_object() {
649 for (key, val) in obj {
650 let fragment = escape_gleam(&format!("\"{}\":", key));
651 let _ = writeln!(
652 out,
653 " {response_var}.body |> string.contains(\"{fragment}\") |> should.equal(True)"
654 );
655 let _ = val; }
657 }
658 }
659
660 fn render_assert_validation_errors(
666 &self,
667 out: &mut String,
668 response_var: &str,
669 errors: &[ValidationErrorExpectation],
670 ) {
671 for err in errors {
672 let escaped_msg = escape_gleam(&err.msg);
673 let _ = writeln!(
674 out,
675 " {response_var}.body |> string.contains(\"{escaped_msg}\") |> should.equal(True)"
676 );
677 }
678 }
679}
680
681fn render_http_test_case(out: &mut String, fixture: &Fixture) {
687 client::http_call::render_http_test(out, &GleamTestClientRenderer, fixture);
688}
689
690#[allow(clippy::too_many_arguments)]
691fn render_test_case(
692 out: &mut String,
693 fixture: &Fixture,
694 e2e_config: &E2eConfig,
695 module_path: &str,
696 _function_name: &str,
697 _result_var: &str,
698 _args: &[crate::config::ArgMapping],
699 field_resolver: &FieldResolver,
700 enum_fields: &HashSet<String>,
701 element_constructors: &[alef_core::config::GleamElementConstructor],
702 json_object_wrapper: Option<&str>,
703) {
704 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
706 let lang = "gleam";
707 let call_overrides = call_config.overrides.get(lang);
708 let function_name = call_overrides
709 .and_then(|o| o.function.as_ref())
710 .cloned()
711 .unwrap_or_else(|| call_config.function.clone());
712 let result_var = &call_config.result_var;
713 let args = &call_config.args;
714
715 let raw_name = sanitize_ident(&fixture.id);
720 let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
721 let test_name = if stripped.is_empty() {
722 raw_name.as_str()
723 } else {
724 stripped
725 };
726 let description = &fixture.description;
727 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
728
729 let test_documents_path = e2e_config.test_documents_relative_from(0);
730 let (setup_lines, args_str) = build_args_and_setup(
731 &fixture.input,
732 args,
733 &fixture.id,
734 &test_documents_path,
735 element_constructors,
736 json_object_wrapper,
737 );
738
739 let _ = writeln!(out, "// {description}");
742 let _ = writeln!(out, "pub fn {test_name}_test() {{");
743
744 for line in &setup_lines {
745 let _ = writeln!(out, " {line}");
746 }
747
748 if expects_error {
749 let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
750 let _ = writeln!(out, "}}");
751 return;
752 }
753
754 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
755 let _ = writeln!(out, " {result_var} |> should.be_ok()");
756 let _ = writeln!(out, " let assert Ok(r) = {result_var}");
757
758 let result_is_array = call_config.result_is_array || call_config.result_is_vec;
759 let pkg_module = e2e_config
764 .resolve_package("gleam")
765 .as_ref()
766 .and_then(|p| p.name.as_ref())
767 .cloned()
768 .unwrap_or_else(|| {
769 module_path
770 .split('.')
771 .next()
772 .unwrap_or(module_path)
773 .to_string()
774 });
775 for assertion in &fixture.assertions {
776 render_assertion(
777 out,
778 assertion,
779 "r",
780 field_resolver,
781 enum_fields,
782 result_is_array,
783 &pkg_module,
784 );
785 }
786
787 let _ = writeln!(out, "}}");
788}
789
790fn build_args_and_setup(
801 input: &serde_json::Value,
802 args: &[crate::config::ArgMapping],
803 _fixture_id: &str,
804 test_documents_path: &str,
805 element_constructors: &[alef_core::config::GleamElementConstructor],
806 json_object_wrapper: Option<&str>,
807) -> (Vec<String>, String) {
808 if args.is_empty() {
809 return (Vec::new(), String::new());
810 }
811
812 let mut setup_lines: Vec<String> = Vec::new();
813 let mut parts: Vec<String> = Vec::new();
814 let mut bytes_var_counter = 0usize;
815
816 for arg in args {
817 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
818 let val = input.get(field);
819
820 match arg.arg_type.as_str() {
821 "file_path" => {
822 let path = val.and_then(|v| v.as_str()).unwrap_or("");
826 let full_path = format!("{test_documents_path}/{path}");
827 parts.push(format!("\"{}\"", escape_gleam(&full_path)));
828 }
829 "bytes" => {
830 let path = val.and_then(|v| v.as_str()).unwrap_or("");
834 let var_name = if bytes_var_counter == 0 {
835 "data_bytes__".to_string()
836 } else {
837 format!("data_bytes_{bytes_var_counter}__")
838 };
839 bytes_var_counter += 1;
840 let full_path = format!("{test_documents_path}/{path}");
842 setup_lines.push(format!(
843 "let assert Ok({var_name}) = e2e_gleam.read_file_bytes(\"{}\")",
844 escape_gleam(&full_path)
845 ));
846 parts.push(var_name);
847 }
848 "string" if arg.optional => {
849 match val {
851 None | Some(serde_json::Value::Null) => {
852 parts.push("option.None".to_string());
853 }
854 Some(serde_json::Value::String(s)) if s.is_empty() => {
855 parts.push("option.None".to_string());
856 }
857 Some(serde_json::Value::String(s)) => {
858 parts.push(format!("option.Some(\"{}\")", escape_gleam(s)));
859 }
860 Some(v) => {
861 parts.push(format!("option.Some({})", json_to_gleam(v)));
862 }
863 }
864 }
865 "string" => {
866 match val {
868 None | Some(serde_json::Value::Null) => {
869 parts.push("\"\"".to_string());
870 }
871 Some(serde_json::Value::String(s)) => {
872 parts.push(format!("\"{}\"", escape_gleam(s)));
873 }
874 Some(v) => {
875 parts.push(json_to_gleam(v));
876 }
877 }
878 }
879 "json_object" => {
880 let element_type = arg.element_type.as_deref().unwrap_or("");
885 let recipe = if element_type.is_empty() {
886 None
887 } else {
888 element_constructors
889 .iter()
890 .find(|r| r.element_type == element_type)
891 };
892
893 if let Some(recipe) = recipe {
894 let items_expr = match val {
898 Some(serde_json::Value::Array(arr)) => {
899 let items: Vec<String> = arr
900 .iter()
901 .map(|item| {
902 render_gleam_element_constructor(item, recipe, test_documents_path)
903 })
904 .collect();
905 format!("[{}]", items.join(", "))
906 }
907 _ => "[]".to_string(),
908 };
909 if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
910 parts.push("[]".to_string());
911 } else {
912 parts.push(items_expr);
913 }
914 } else if arg.optional && (val.is_none() || val == Some(&serde_json::Value::Null)) {
915 parts.push("option.None".to_string());
916 } else {
917 let empty_obj = serde_json::Value::Object(Default::default());
918 let config_val = val.unwrap_or(&empty_obj);
919 let json_literal = json_to_gleam(config_val);
920 let emitted = match json_object_wrapper {
925 Some(template) => template.replace("{json}", &json_literal),
926 None => json_literal,
927 };
928 parts.push(emitted);
929 }
930 }
931 "int" | "integer" => match val {
932 None | Some(serde_json::Value::Null) if arg.optional => {}
933 None | Some(serde_json::Value::Null) => parts.push("0".to_string()),
934 Some(v) => parts.push(json_to_gleam(v)),
935 },
936 "bool" | "boolean" => match val {
937 Some(serde_json::Value::Bool(true)) => parts.push("True".to_string()),
938 Some(serde_json::Value::Bool(false)) | None | Some(serde_json::Value::Null) => {
939 if !arg.optional {
940 parts.push("False".to_string());
941 }
942 }
943 Some(v) => parts.push(json_to_gleam(v)),
944 },
945 _ => {
946 match val {
948 None | Some(serde_json::Value::Null) if arg.optional => {}
949 None | Some(serde_json::Value::Null) => parts.push("Nil".to_string()),
950 Some(v) => parts.push(json_to_gleam(v)),
951 }
952 }
953 }
954 }
955
956 (setup_lines, parts.join(", "))
957}
958
959fn render_gleam_element_constructor(
971 item: &serde_json::Value,
972 recipe: &alef_core::config::GleamElementConstructor,
973 test_documents_path: &str,
974) -> String {
975 let mut field_exprs: Vec<String> = Vec::with_capacity(recipe.fields.len());
976 for field in &recipe.fields {
977 let expr = match field.kind.as_str() {
978 "file_path" => {
979 let json_field = field.json_field.as_deref().unwrap_or("");
980 let path = item.get(json_field).and_then(|v| v.as_str()).unwrap_or("");
981 let full = if path.starts_with('/') {
982 path.to_string()
983 } else {
984 format!("{test_documents_path}/{path}")
985 };
986 format!("\"{}\"", escape_gleam(&full))
987 }
988 "byte_array" => {
989 let json_field = field.json_field.as_deref().unwrap_or("");
990 let bytes: Vec<String> = item
991 .get(json_field)
992 .and_then(|v| v.as_array())
993 .map(|arr| arr.iter().map(|b| b.as_u64().unwrap_or(0).to_string()).collect())
994 .unwrap_or_default();
995 if bytes.is_empty() {
996 "<<>>".to_string()
997 } else {
998 format!("<<{}>>", bytes.join(", "))
999 }
1000 }
1001 "string" => {
1002 let json_field = field.json_field.as_deref().unwrap_or("");
1003 let value = item
1004 .get(json_field)
1005 .and_then(|v| v.as_str())
1006 .map(str::to_string)
1007 .or_else(|| field.default.clone())
1008 .unwrap_or_default();
1009 format!("\"{}\"", escape_gleam(&value))
1010 }
1011 "literal" => field.value.clone().unwrap_or_default(),
1012 other => {
1013 field.value.clone().unwrap_or_else(|| format!("\"<unsupported kind: {other}>\""))
1018 }
1019 };
1020 field_exprs.push(format!("{}: {}", field.gleam_field, expr));
1021 }
1022 format!("{}({})", recipe.constructor, field_exprs.join(", "))
1023}
1024
1025fn render_tagged_union_assertion(
1034 out: &mut String,
1035 assertion: &Assertion,
1036 result_var: &str,
1037 prefix: &str,
1038 variant: &str,
1039 suffix: &str,
1040 field_resolver: &FieldResolver,
1041 pkg_module: &str,
1042) {
1043 let prefix_expr = if prefix.is_empty() {
1046 result_var.to_string()
1047 } else {
1048 format!("{result_var}.{prefix}")
1049 };
1050
1051 let constructor = variant.to_pascal_case();
1055 let module_qualifier = pkg_module;
1059
1060 let inner_var = "fmt_inner__";
1062
1063 let full_suffix_path = if prefix.is_empty() {
1066 format!("{variant}.{suffix}")
1067 } else {
1068 format!("{prefix}.{variant}.{suffix}")
1069 };
1070 let suffix_is_optional = field_resolver.is_optional(&full_suffix_path);
1071 let suffix_is_array = field_resolver.is_array(&full_suffix_path);
1072
1073 let _ = writeln!(out, " case {prefix_expr} {{");
1075 let _ = writeln!(
1076 out,
1077 " option.Some({module_qualifier}.{constructor}({inner_var})) -> {{"
1078 );
1079
1080 let inner_field_expr = if suffix.is_empty() {
1082 inner_var.to_string()
1083 } else {
1084 format!("{inner_var}.{suffix}")
1085 };
1086
1087 match assertion.assertion_type.as_str() {
1089 "equals" => {
1090 if let Some(expected) = &assertion.value {
1091 let gleam_val = json_to_gleam(expected);
1092 if suffix_is_optional {
1093 let default = default_gleam_value_for_optional(&gleam_val);
1094 let _ = writeln!(
1095 out,
1096 " {inner_field_expr} |> option.unwrap({default}) |> should.equal({gleam_val})"
1097 );
1098 } else {
1099 let _ = writeln!(out, " {inner_field_expr} |> should.equal({gleam_val})");
1100 }
1101 }
1102 }
1103 "contains" => {
1104 if let Some(expected) = &assertion.value {
1105 let gleam_val = json_to_gleam(expected);
1106 if suffix_is_array {
1107 let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
1109 let _ = writeln!(
1110 out,
1111 " items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1112 );
1113 } else if suffix_is_optional {
1114 let _ = writeln!(
1115 out,
1116 " {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1117 );
1118 } else {
1119 let _ = writeln!(
1120 out,
1121 " {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1122 );
1123 }
1124 }
1125 }
1126 "contains_all" => {
1127 if let Some(values) = &assertion.values {
1128 if suffix_is_array {
1129 let _ = writeln!(out, " let items__ = {inner_field_expr} |> option.unwrap([])");
1131 for val in values {
1132 let gleam_val = json_to_gleam(val);
1133 let _ = writeln!(
1134 out,
1135 " items__ |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1136 );
1137 }
1138 } else if suffix_is_optional {
1139 for val in values {
1140 let gleam_val = json_to_gleam(val);
1141 let _ = writeln!(
1142 out,
1143 " {inner_field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1144 );
1145 }
1146 } else {
1147 for val in values {
1148 let gleam_val = json_to_gleam(val);
1149 let _ = writeln!(
1150 out,
1151 " {inner_field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1152 );
1153 }
1154 }
1155 }
1156 }
1157 "greater_than_or_equal" => {
1158 if let Some(val) = &assertion.value {
1159 let gleam_val = json_to_gleam(val);
1160 if suffix_is_optional {
1161 let _ = writeln!(
1162 out,
1163 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1164 );
1165 } else {
1166 let _ = writeln!(
1167 out,
1168 " {inner_field_expr} |> fn(n__) {{ n__ >= {gleam_val} }} |> should.equal(True)"
1169 );
1170 }
1171 }
1172 }
1173 "greater_than" => {
1174 if let Some(val) = &assertion.value {
1175 let gleam_val = json_to_gleam(val);
1176 if suffix_is_optional {
1177 let _ = writeln!(
1178 out,
1179 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1180 );
1181 } else {
1182 let _ = writeln!(
1183 out,
1184 " {inner_field_expr} |> fn(n__) {{ n__ > {gleam_val} }} |> should.equal(True)"
1185 );
1186 }
1187 }
1188 }
1189 "less_than" => {
1190 if let Some(val) = &assertion.value {
1191 let gleam_val = json_to_gleam(val);
1192 if suffix_is_optional {
1193 let _ = writeln!(
1194 out,
1195 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1196 );
1197 } else {
1198 let _ = writeln!(
1199 out,
1200 " {inner_field_expr} |> fn(n__) {{ n__ < {gleam_val} }} |> should.equal(True)"
1201 );
1202 }
1203 }
1204 }
1205 "less_than_or_equal" => {
1206 if let Some(val) = &assertion.value {
1207 let gleam_val = json_to_gleam(val);
1208 if suffix_is_optional {
1209 let _ = writeln!(
1210 out,
1211 " {inner_field_expr} |> option.unwrap(0) |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1212 );
1213 } else {
1214 let _ = writeln!(
1215 out,
1216 " {inner_field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1217 );
1218 }
1219 }
1220 }
1221 "count_min" => {
1222 if let Some(val) = &assertion.value {
1223 if let Some(n) = val.as_u64() {
1224 if suffix_is_optional {
1225 let _ = writeln!(
1226 out,
1227 " {inner_field_expr} |> option.unwrap([]) |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1228 );
1229 } else {
1230 let _ = writeln!(
1231 out,
1232 " {inner_field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1233 );
1234 }
1235 }
1236 }
1237 }
1238 "count_equals" => {
1239 if let Some(val) = &assertion.value {
1240 if let Some(n) = val.as_u64() {
1241 if suffix_is_optional {
1242 let _ = writeln!(
1243 out,
1244 " {inner_field_expr} |> option.unwrap([]) |> list.length |> should.equal({n})"
1245 );
1246 } else {
1247 let _ = writeln!(out, " {inner_field_expr} |> list.length |> should.equal({n})");
1248 }
1249 }
1250 }
1251 }
1252 "not_empty" => {
1253 if suffix_is_optional {
1254 let _ = writeln!(
1255 out,
1256 " {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(False)"
1257 );
1258 } else {
1259 let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(False)");
1260 }
1261 }
1262 "is_empty" => {
1263 if suffix_is_optional {
1264 let _ = writeln!(
1265 out,
1266 " {inner_field_expr} |> option.unwrap([]) |> list.is_empty |> should.equal(True)"
1267 );
1268 } else {
1269 let _ = writeln!(out, " {inner_field_expr} |> list.is_empty |> should.equal(True)");
1270 }
1271 }
1272 "is_true" => {
1273 let _ = writeln!(out, " {inner_field_expr} |> should.equal(True)");
1274 }
1275 "is_false" => {
1276 let _ = writeln!(out, " {inner_field_expr} |> should.equal(False)");
1277 }
1278 other => {
1279 let _ = writeln!(
1280 out,
1281 " // tagged-union assertion '{other}' not yet implemented for Gleam"
1282 );
1283 }
1284 }
1285
1286 let _ = writeln!(out, " }}");
1288 let _ = writeln!(
1289 out,
1290 " _ -> panic as \"expected {module_qualifier}.{constructor} format metadata\""
1291 );
1292 let _ = writeln!(out, " }}");
1293}
1294
1295fn default_gleam_value_for_optional(gleam_val: &str) -> &'static str {
1298 if gleam_val.starts_with('"') {
1299 "\"\""
1300 } else if gleam_val == "True" || gleam_val == "False" {
1301 "False"
1302 } else if gleam_val.contains('.') {
1303 "0.0"
1304 } else {
1305 "0"
1306 }
1307}
1308
1309fn render_assertion(
1310 out: &mut String,
1311 assertion: &Assertion,
1312 result_var: &str,
1313 field_resolver: &FieldResolver,
1314 enum_fields: &HashSet<String>,
1315 result_is_array: bool,
1316 pkg_module: &str,
1317) {
1318 if let Some(f) = &assertion.field {
1320 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1321 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1322 return;
1323 }
1324 }
1325
1326 if let Some(f) = &assertion.field {
1330 if !f.is_empty() {
1331 if let Some((prefix, variant, suffix)) = field_resolver.tagged_union_split(f) {
1332 render_tagged_union_assertion(
1333 out,
1334 assertion,
1335 result_var,
1336 &prefix,
1337 &variant,
1338 &suffix,
1339 field_resolver,
1340 pkg_module,
1341 );
1342 return;
1343 }
1344 }
1345 }
1346
1347 if let Some(f) = &assertion.field {
1350 if !f.is_empty() {
1351 let parts: Vec<&str> = f.split('.').collect();
1352 let mut opt_prefix: Option<(String, usize)> = None;
1353 for i in 1..parts.len() {
1354 let prefix_path = parts[..i].join(".");
1355 if field_resolver.is_optional(&prefix_path) {
1356 opt_prefix = Some((prefix_path, i));
1357 break;
1358 }
1359 }
1360 if let Some((optional_prefix, suffix_start)) = opt_prefix {
1361 let prefix_expr = format!("{result_var}.{optional_prefix}");
1362 let suffix_parts = &parts[suffix_start..];
1363 let suffix_str = suffix_parts.join(".");
1364 let inner_var = "opt_inner__";
1365 let inner_expr = if suffix_str.is_empty() {
1366 inner_var.to_string()
1367 } else {
1368 format!("{inner_var}.{suffix_str}")
1369 };
1370 let _ = writeln!(out, " case {prefix_expr} {{");
1371 let _ = writeln!(out, " option.Some({inner_var}) -> {{");
1372 match assertion.assertion_type.as_str() {
1373 "count_min" => {
1374 if let Some(val) = &assertion.value {
1375 if let Some(n) = val.as_u64() {
1376 let _ = writeln!(
1377 out,
1378 " {inner_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1379 );
1380 }
1381 }
1382 }
1383 "count_equals" => {
1384 if let Some(val) = &assertion.value {
1385 if let Some(n) = val.as_u64() {
1386 let _ = writeln!(out, " {inner_expr} |> list.length |> should.equal({n})");
1387 }
1388 }
1389 }
1390 "not_empty" => {
1391 let _ = writeln!(out, " {inner_expr} |> list.is_empty |> should.equal(False)");
1392 }
1393 "min_length" => {
1394 if let Some(val) = &assertion.value {
1395 if let Some(n) = val.as_u64() {
1396 let _ = writeln!(
1397 out,
1398 " {inner_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1399 );
1400 }
1401 }
1402 }
1403 other => {
1404 let _ = writeln!(
1405 out,
1406 " // optional-prefix assertion '{other}' not yet implemented for Gleam"
1407 );
1408 }
1409 }
1410 let _ = writeln!(out, " }}");
1411 let _ = writeln!(out, " option.None -> should.fail()");
1412 let _ = writeln!(out, " }}");
1413 return;
1414 }
1415 }
1416 }
1417
1418 let field_is_optional = assertion
1421 .field
1422 .as_deref()
1423 .is_some_and(|f| !f.is_empty() && field_resolver.is_optional(f));
1424
1425 let _field_is_enum = assertion
1427 .field
1428 .as_deref()
1429 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1430
1431 let field_expr = match &assertion.field {
1432 Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
1433 _ => result_var.to_string(),
1434 };
1435
1436 let field_is_array = {
1439 let f = assertion.field.as_deref().unwrap_or("");
1440 let is_root = f.is_empty();
1441 (is_root && result_is_array) || field_resolver.is_array(f) || field_resolver.is_array(field_resolver.resolve(f))
1442 };
1443
1444 match assertion.assertion_type.as_str() {
1445 "equals" => {
1446 if let Some(expected) = &assertion.value {
1447 let gleam_val = json_to_gleam(expected);
1448 if field_is_optional {
1449 let _ = writeln!(out, " {field_expr} |> should.equal(option.Some({gleam_val}))");
1451 } else {
1452 let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
1453 }
1454 }
1455 }
1456 "contains" => {
1457 if let Some(expected) = &assertion.value {
1458 let gleam_val = json_to_gleam(expected);
1459 if field_is_array {
1460 let _ = writeln!(
1462 out,
1463 " {field_expr} |> list.any(fn(item__) {{ string.contains(item__, {gleam_val}) }}) |> should.equal(True)"
1464 );
1465 } else if field_is_optional {
1466 let _ = writeln!(
1467 out,
1468 " {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1469 );
1470 } else {
1471 let _ = writeln!(
1472 out,
1473 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1474 );
1475 }
1476 }
1477 }
1478 "contains_all" => {
1479 if let Some(values) = &assertion.values {
1480 for val in values {
1481 let gleam_val = json_to_gleam(val);
1482 if field_is_optional {
1483 let _ = writeln!(
1484 out,
1485 " {field_expr} |> option.unwrap(\"\") |> string.contains({gleam_val}) |> should.equal(True)"
1486 );
1487 } else {
1488 let _ = writeln!(
1489 out,
1490 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
1491 );
1492 }
1493 }
1494 }
1495 }
1496 "not_contains" => {
1497 if let Some(expected) = &assertion.value {
1498 let gleam_val = json_to_gleam(expected);
1499 let _ = writeln!(
1500 out,
1501 " {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
1502 );
1503 }
1504 }
1505 "not_empty" => {
1506 if field_is_optional {
1507 let _ = writeln!(out, " {field_expr} |> option.is_some |> should.equal(True)");
1509 } else {
1510 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
1511 }
1512 }
1513 "is_empty" => {
1514 if field_is_optional {
1515 let _ = writeln!(out, " {field_expr} |> option.is_none |> should.equal(True)");
1516 } else {
1517 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
1518 }
1519 }
1520 "starts_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.starts_with({gleam_val}) |> should.equal(True)"
1526 );
1527 }
1528 }
1529 "ends_with" => {
1530 if let Some(expected) = &assertion.value {
1531 let gleam_val = json_to_gleam(expected);
1532 let _ = writeln!(
1533 out,
1534 " {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
1535 );
1536 }
1537 }
1538 "min_length" => {
1539 if let Some(val) = &assertion.value {
1540 if let Some(n) = val.as_u64() {
1541 let _ = writeln!(
1542 out,
1543 " {field_expr} |> string.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1544 );
1545 }
1546 }
1547 }
1548 "max_length" => {
1549 if let Some(val) = &assertion.value {
1550 if let Some(n) = val.as_u64() {
1551 let _ = writeln!(
1552 out,
1553 " {field_expr} |> string.length |> fn(n__) {{ n__ <= {n} }} |> should.equal(True)"
1554 );
1555 }
1556 }
1557 }
1558 "count_min" => {
1559 if let Some(val) = &assertion.value {
1560 if let Some(n) = val.as_u64() {
1561 let _ = writeln!(
1562 out,
1563 " {field_expr} |> list.length |> fn(n__) {{ n__ >= {n} }} |> should.equal(True)"
1564 );
1565 }
1566 }
1567 }
1568 "count_equals" => {
1569 if let Some(val) = &assertion.value {
1570 if let Some(n) = val.as_u64() {
1571 let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
1572 }
1573 }
1574 }
1575 "is_true" => {
1576 let _ = writeln!(out, " {field_expr} |> should.equal(True)");
1577 }
1578 "is_false" => {
1579 let _ = writeln!(out, " {field_expr} |> should.equal(False)");
1580 }
1581 "not_error" => {
1582 }
1584 "error" => {
1585 }
1587 "greater_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 "less_than" => {
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 "greater_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 "less_than_or_equal" => {
1615 if let Some(val) = &assertion.value {
1616 let gleam_val = json_to_gleam(val);
1617 let _ = writeln!(
1618 out,
1619 " {field_expr} |> fn(n__) {{ n__ <= {gleam_val} }} |> should.equal(True)"
1620 );
1621 }
1622 }
1623 "contains_any" => {
1624 if let Some(values) = &assertion.values {
1625 let vals_list = values.iter().map(json_to_gleam).collect::<Vec<_>>().join(", ");
1626 let _ = writeln!(
1627 out,
1628 " [{vals_list}] |> list.any(fn(v__) {{ string.contains({field_expr}, v__) }}) |> should.equal(True)"
1629 );
1630 }
1631 }
1632 "matches_regex" => {
1633 let _ = writeln!(out, " // regex match not yet implemented for Gleam");
1634 }
1635 "method_result" => {
1636 let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
1637 }
1638 other => {
1639 panic!("Gleam e2e generator: unsupported assertion type: {other}");
1640 }
1641 }
1642}
1643
1644fn json_to_gleam(value: &serde_json::Value) -> String {
1646 match value {
1647 serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
1648 serde_json::Value::Bool(b) => {
1649 if *b {
1650 "True".to_string()
1651 } else {
1652 "False".to_string()
1653 }
1654 }
1655 serde_json::Value::Number(n) => n.to_string(),
1656 serde_json::Value::Null => "Nil".to_string(),
1657 serde_json::Value::Array(arr) => {
1658 let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
1659 format!("[{}]", items.join(", "))
1660 }
1661 serde_json::Value::Object(_) => {
1662 let json_str = serde_json::to_string(value).unwrap_or_default();
1663 format!("\"{}\"", escape_gleam(&json_str))
1664 }
1665 }
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670 use super::*;
1671 use alef_core::config::{GleamElementConstructor, GleamElementField};
1672
1673 fn batch_file_item_recipe() -> GleamElementConstructor {
1674 GleamElementConstructor {
1675 element_type: "BatchFileItem".to_string(),
1676 constructor: "kreuzberg.BatchFileItem".to_string(),
1677 fields: vec![
1678 GleamElementField {
1679 gleam_field: "path".to_string(),
1680 kind: "file_path".to_string(),
1681 json_field: Some("path".to_string()),
1682 default: None,
1683 value: None,
1684 },
1685 GleamElementField {
1686 gleam_field: "config".to_string(),
1687 kind: "literal".to_string(),
1688 json_field: None,
1689 default: None,
1690 value: Some("option.None".to_string()),
1691 },
1692 ],
1693 }
1694 }
1695
1696 #[test]
1697 fn render_element_constructor_file_path_relative_path_gets_test_documents_prefix() {
1698 let item = serde_json::json!({ "path": "docx/fake.docx" });
1699 let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
1700 assert_eq!(
1701 out,
1702 "kreuzberg.BatchFileItem(path: \"../../test_documents/docx/fake.docx\", config: option.None)"
1703 );
1704 }
1705
1706 #[test]
1707 fn render_element_constructor_file_path_absolute_path_passes_through() {
1708 let item = serde_json::json!({ "path": "/etc/some/absolute" });
1709 let out = render_gleam_element_constructor(&item, &batch_file_item_recipe(), "../../test_documents");
1710 assert!(
1711 out.contains("\"/etc/some/absolute\""),
1712 "absolute paths must NOT receive the test_documents prefix; got:\n{out}"
1713 );
1714 }
1715
1716 #[test]
1717 fn render_element_constructor_byte_array_emits_bitarray() {
1718 let recipe = GleamElementConstructor {
1719 element_type: "BatchBytesItem".to_string(),
1720 constructor: "kreuzberg.BatchBytesItem".to_string(),
1721 fields: vec![
1722 GleamElementField {
1723 gleam_field: "content".to_string(),
1724 kind: "byte_array".to_string(),
1725 json_field: Some("content".to_string()),
1726 default: None,
1727 value: None,
1728 },
1729 GleamElementField {
1730 gleam_field: "mime_type".to_string(),
1731 kind: "string".to_string(),
1732 json_field: Some("mime_type".to_string()),
1733 default: Some("text/plain".to_string()),
1734 value: None,
1735 },
1736 GleamElementField {
1737 gleam_field: "config".to_string(),
1738 kind: "literal".to_string(),
1739 json_field: None,
1740 default: None,
1741 value: Some("option.None".to_string()),
1742 },
1743 ],
1744 };
1745 let item = serde_json::json!({ "content": [72, 105], "mime_type": "text/html" });
1746 let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
1747 assert_eq!(
1748 out,
1749 "kreuzberg.BatchBytesItem(content: <<72, 105>>, mime_type: \"text/html\", config: option.None)"
1750 );
1751 }
1752
1753 #[test]
1754 fn build_args_with_json_object_wrapper_substitutes_placeholder() {
1755 use crate::config::ArgMapping;
1756 let arg = ArgMapping {
1757 name: "config".to_string(),
1758 field: "config".to_string(),
1759 arg_type: "json_object".to_string(),
1760 optional: false,
1761 owned: false,
1762 element_type: None,
1763 go_type: None,
1764 };
1765 let input = serde_json::json!({
1766 "config": { "use_cache": true, "force_ocr": false }
1767 });
1768 let (_setup, args_str) = build_args_and_setup(
1769 &input,
1770 &[arg],
1771 "test_fixture",
1772 "../../test_documents",
1773 &[],
1774 Some("k.config_from_json_string({json})"),
1775 );
1776 assert!(
1779 args_str.starts_with("k.config_from_json_string("),
1780 "wrapper must envelop the JSON literal; got:\n{args_str}"
1781 );
1782 assert!(
1783 args_str.contains("use_cache"),
1784 "JSON payload must reach the wrapper; got:\n{args_str}"
1785 );
1786 }
1787
1788 #[test]
1789 fn build_args_without_json_object_wrapper_emits_bare_json_string() {
1790 use crate::config::ArgMapping;
1791 let arg = ArgMapping {
1792 name: "config".to_string(),
1793 field: "config".to_string(),
1794 arg_type: "json_object".to_string(),
1795 optional: false,
1796 owned: false,
1797 element_type: None,
1798 go_type: None,
1799 };
1800 let input = serde_json::json!({ "config": { "x": 1 } });
1801 let (_setup, args_str) = build_args_and_setup(
1802 &input,
1803 &[arg],
1804 "test_fixture",
1805 "../../test_documents",
1806 &[],
1807 None,
1808 );
1809 assert!(
1812 !args_str.contains("from_json_string"),
1813 "no wrapper configured must not synthesise one; got:\n{args_str}"
1814 );
1815 assert!(
1816 args_str.starts_with('"'),
1817 "bare emission is a Gleam string literal starting with a quote; got:\n{args_str}"
1818 );
1819 }
1820
1821 #[test]
1822 fn render_element_constructor_string_falls_back_to_default() {
1823 let recipe = GleamElementConstructor {
1824 element_type: "BatchBytesItem".to_string(),
1825 constructor: "k.BatchBytesItem".to_string(),
1826 fields: vec![GleamElementField {
1827 gleam_field: "mime_type".to_string(),
1828 kind: "string".to_string(),
1829 json_field: Some("mime_type".to_string()),
1830 default: Some("text/plain".to_string()),
1831 value: None,
1832 }],
1833 };
1834 let item = serde_json::json!({});
1835 let out = render_gleam_element_constructor(&item, &recipe, "../../test_documents");
1836 assert!(
1837 out.contains("mime_type: \"text/plain\""),
1838 "missing string field must fall back to default; got:\n{out}"
1839 );
1840 }
1841}