1use crate::config::E2eConfig;
9use crate::escape::{escape_gleam, sanitize_filename, sanitize_ident};
10use crate::field_access::FieldResolver;
11use crate::fixture::{Assertion, Fixture, FixtureGroup};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::AlefConfig;
14use alef_core::hash::{self, CommentStyle};
15use anyhow::Result;
16use heck::ToSnakeCase;
17use std::collections::HashSet;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22
23pub struct GleamE2eCodegen;
25
26impl E2eCodegen for GleamE2eCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 alef_config: &AlefConfig,
32 ) -> Result<Vec<GeneratedFile>> {
33 let lang = self.language_name();
34 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36 let mut files = Vec::new();
37
38 let call = &e2e_config.call;
40 let overrides = call.overrides.get(lang);
41 let module_path = overrides
42 .and_then(|o| o.module.as_ref())
43 .cloned()
44 .unwrap_or_else(|| call.module.clone());
45 let function_name = overrides
46 .and_then(|o| o.function.as_ref())
47 .cloned()
48 .unwrap_or_else(|| call.function.clone());
49 let result_var = &call.result_var;
50
51 let gleam_pkg = e2e_config.resolve_package("gleam");
53 let pkg_path = gleam_pkg
54 .as_ref()
55 .and_then(|p| p.path.as_ref())
56 .cloned()
57 .unwrap_or_else(|| "../../packages/gleam".to_string());
58 let pkg_name = gleam_pkg
59 .as_ref()
60 .and_then(|p| p.name.as_ref())
61 .cloned()
62 .unwrap_or_else(|| alef_config.crate_config.name.to_snake_case());
63
64 files.push(GeneratedFile {
66 path: output_base.join("gleam.toml"),
67 content: render_gleam_toml(&pkg_path, &pkg_name, e2e_config.dep_mode),
68 generated_header: false,
69 });
70
71 files.push(GeneratedFile {
74 path: output_base.join("src").join("e2e_gleam.gleam"),
75 content: "// Generated by alef. Do not edit by hand.\n// Placeholder module — e2e tests live in test/.\npub fn placeholder() -> Nil {\n Nil\n}\n".to_string(),
76 generated_header: false,
77 });
78
79 let mut any_tests = false;
81
82 for group in groups {
84 let active: Vec<&Fixture> = group
85 .fixtures
86 .iter()
87 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
89 .filter(|f| {
93 if let Some(http) = &f.http {
94 let has_upgrade = http
95 .request
96 .headers
97 .iter()
98 .any(|(k, v)| k.eq_ignore_ascii_case("upgrade") && v.eq_ignore_ascii_case("websocket"));
99 !has_upgrade
100 } else {
101 true
102 }
103 })
104 .filter(|f| {
106 if f.is_http_test() {
107 true
108 } else {
109 let call_cfg = e2e_config.resolve_call(f.call.as_deref());
110 call_cfg.overrides.contains_key(lang)
111 }
112 })
113 .collect();
114
115 if active.is_empty() {
116 continue;
117 }
118
119 let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
120 let field_resolver = FieldResolver::new(
121 &e2e_config.fields,
122 &e2e_config.fields_optional,
123 &e2e_config.result_fields,
124 &e2e_config.fields_array,
125 );
126 let content = render_test_file(
127 &group.category,
128 &active,
129 e2e_config,
130 &module_path,
131 &function_name,
132 result_var,
133 &e2e_config.call.args,
134 &field_resolver,
135 &e2e_config.fields_enum,
136 );
137 files.push(GeneratedFile {
138 path: output_base.join("test").join(filename),
139 content,
140 generated_header: true,
141 });
142 any_tests = true;
143 }
144
145 let entry = if any_tests {
150 concat!(
151 "// Generated by alef. Do not edit by hand.\n",
152 "import gleeunit\n",
153 "\n",
154 "pub fn main() {\n",
155 " gleeunit.main()\n",
156 "}\n",
157 )
158 .to_string()
159 } else {
160 concat!(
161 "// Generated by alef. Do not edit by hand.\n",
162 "// No fixture-driven tests for Gleam — e2e tests require HTTP fixtures\n",
163 "// or non-HTTP fixtures with gleam-specific call overrides.\n",
164 "import gleeunit\n",
165 "import gleeunit/should\n",
166 "\n",
167 "pub fn main() {\n",
168 " gleeunit.main()\n",
169 "}\n",
170 "\n",
171 "pub fn compilation_smoke_test() {\n",
172 " True |> should.equal(True)\n",
173 "}\n",
174 )
175 .to_string()
176 };
177 files.push(GeneratedFile {
178 path: output_base.join("test").join("e2e_gleam_test.gleam"),
179 content: entry,
180 generated_header: false,
181 });
182
183 Ok(files)
184 }
185
186 fn language_name(&self) -> &'static str {
187 "gleam"
188 }
189}
190
191fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
196 use alef_core::template_versions::hex;
197 let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
198 let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
199 let gleam_httpc = hex::GLEAM_HTTPC_VERSION_RANGE;
200 let envoy = hex::ENVOY_VERSION_RANGE;
201 let deps = match dep_mode {
202 crate::config::DependencyMode::Registry => {
203 format!(
204 r#"{pkg_name} = ">= 0.1.0"
205gleam_stdlib = "{stdlib}"
206gleeunit = "{gleeunit}"
207gleam_httpc = "{gleam_httpc}"
208gleam_http = ">= 4.0.0 and < 5.0.0"
209envoy = "{envoy}""#
210 )
211 }
212 crate::config::DependencyMode::Local => {
213 format!(
214 r#"{pkg_name} = {{ path = "{pkg_path}" }}
215gleam_stdlib = "{stdlib}"
216gleeunit = "{gleeunit}"
217gleam_httpc = "{gleam_httpc}"
218gleam_http = ">= 4.0.0 and < 5.0.0"
219envoy = "{envoy}""#
220 )
221 }
222 };
223
224 format!(
225 r#"name = "e2e_gleam"
226version = "0.1.0"
227target = "erlang"
228
229[dependencies]
230{deps}
231"#
232 )
233}
234
235#[allow(clippy::too_many_arguments)]
236fn render_test_file(
237 _category: &str,
238 fixtures: &[&Fixture],
239 e2e_config: &E2eConfig,
240 module_path: &str,
241 function_name: &str,
242 result_var: &str,
243 args: &[crate::config::ArgMapping],
244 field_resolver: &FieldResolver,
245 enum_fields: &HashSet<String>,
246) -> String {
247 let mut out = String::new();
248 out.push_str(&hash::header(CommentStyle::DoubleSlash));
249 let _ = writeln!(out, "import gleeunit");
250 let _ = writeln!(out, "import gleeunit/should");
251
252 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
254
255 if has_http_fixtures {
257 let _ = writeln!(out, "import gleam/httpc");
258 let _ = writeln!(out, "import gleam/http");
259 let _ = writeln!(out, "import gleam/http/request");
260 let _ = writeln!(out, "import gleam/list");
261 let _ = writeln!(out, "import gleam/result");
262 let _ = writeln!(out, "import gleam/string");
263 let _ = writeln!(out, "import envoy");
264 }
265
266 let has_non_http_with_override = fixtures.iter().any(|f| !f.is_http_test());
268 if has_non_http_with_override {
269 let _ = writeln!(out, "import {module_path}");
270 }
271 let _ = writeln!(out);
272
273 let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
275
276 for fixture in fixtures {
278 if fixture.is_http_test() {
279 continue; }
281 for assertion in &fixture.assertions {
282 match assertion.assertion_type.as_str() {
283 "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" | "min_length"
284 | "max_length" | "contains_any" => {
285 needed_modules.insert("string");
286 }
287 "not_empty" | "is_empty" | "count_min" | "count_equals" => {
288 needed_modules.insert("list");
289 }
290 "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
291 needed_modules.insert("int");
292 }
293 _ => {}
294 }
295 }
296 }
297
298 for module in &needed_modules {
300 let _ = writeln!(out, "import gleam/{module}");
301 }
302
303 if !needed_modules.is_empty() {
304 let _ = writeln!(out);
305 }
306
307 for fixture in fixtures {
309 if fixture.is_http_test() {
310 render_http_test_case(&mut out, fixture);
311 } else {
312 render_test_case(
313 &mut out,
314 fixture,
315 e2e_config,
316 module_path,
317 function_name,
318 result_var,
319 args,
320 field_resolver,
321 enum_fields,
322 );
323 }
324 let _ = writeln!(out);
325 }
326
327 out
328}
329
330fn render_http_test_case(out: &mut String, fixture: &Fixture) {
335 let http = fixture.http.as_ref().unwrap();
336 let description = &fixture.description;
337 let request = &http.request;
338 let expected = &http.expected_response;
339 let method = request.method.to_uppercase();
340 let fixture_id = &fixture.id;
341 let expected_status = expected.status_code;
342 let raw_name = sanitize_ident(&fixture.id);
345 let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
346 let test_name = if stripped.is_empty() {
347 raw_name.as_str()
348 } else {
349 stripped
350 };
351
352 let _ = writeln!(out, "// {description}");
353 let _ = writeln!(out, "pub fn {test_name}_test() {{");
354
355 let _ = writeln!(out, " let base_url = case envoy.get(\"MOCK_SERVER_URL\") {{");
357 let _ = writeln!(out, " Ok(u) -> u");
358 let _ = writeln!(out, " Error(_) -> \"http://localhost:8080\"");
359 let _ = writeln!(out, " }}");
360
361 let _ = writeln!(
363 out,
364 " let assert Ok(req) = request.to(base_url <> \"/fixtures/{fixture_id}\")"
365 );
366
367 let method_const = match method.as_str() {
369 "GET" => "Get",
370 "POST" => "Post",
371 "PUT" => "Put",
372 "DELETE" => "Delete",
373 "PATCH" => "Patch",
374 "HEAD" => "Head",
375 "OPTIONS" => "Options",
376 _ => "Post",
377 };
378 let _ = writeln!(out, " let req = request.set_method(req, http.{method_const})");
379
380 let content_type = request.content_type.as_deref().unwrap_or("application/json");
382 if request.body.is_some() {
383 let _ = writeln!(
384 out,
385 " let req = request.set_header(req, \"content-type\", \"{content_type}\")"
386 );
387 }
388 for (name, value) in &request.headers {
389 let lower_name = name.to_lowercase();
391 if matches!(lower_name.as_str(), "content-length" | "host" | "transfer-encoding") {
392 continue;
393 }
394 let escaped_name = escape_gleam(name);
395 let escaped_value = escape_gleam(value);
396 let _ = writeln!(
397 out,
398 " let req = request.set_header(req, \"{escaped_name}\", \"{escaped_value}\")"
399 );
400 }
401
402 if !request.cookies.is_empty() {
404 let cookie_str: Vec<String> = request.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
405 let cookie_header = escape_gleam(&cookie_str.join("; "));
406 let _ = writeln!(
407 out,
408 " let req = request.set_header(req, \"cookie\", \"{cookie_header}\")"
409 );
410 }
411
412 if let Some(body) = &request.body {
414 let json_str = serde_json::to_string(body).unwrap_or_default();
415 let escaped = escape_gleam(&json_str);
416 let _ = writeln!(out, " let req = request.set_body(req, \"{escaped}\")");
417 }
418
419 let _ = writeln!(out, " let assert Ok(resp) = httpc.send(req)");
421
422 let _ = writeln!(out, " resp.status |> should.equal({expected_status})");
424
425 if let Some(expected_body) = &expected.body {
427 match expected_body {
428 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
429 let json_str = serde_json::to_string(expected_body).unwrap_or_default();
430 let escaped = escape_gleam(&json_str);
431 let _ = writeln!(out, " resp.body |> string.trim |> should.equal(\"{escaped}\")");
432 }
433 serde_json::Value::String(s) => {
434 let escaped = escape_gleam(s);
435 let _ = writeln!(out, " resp.body |> string.trim |> should.equal(\"{escaped}\")");
436 }
437 other => {
438 let escaped = escape_gleam(&other.to_string());
439 let _ = writeln!(out, " resp.body |> string.trim |> should.equal(\"{escaped}\")");
440 }
441 }
442 }
443
444 for (name, value) in &expected.headers {
446 if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
447 continue;
448 }
449 if name.to_lowercase() == "content-encoding" {
452 continue;
453 }
454 let escaped_name = escape_gleam(&name.to_lowercase());
455 let _escaped_value = escape_gleam(value);
456 let _ = writeln!(
457 out,
458 " resp.headers
459 |> list.find(fn(h: #(String, String)) {{ h.0 == \"{escaped_name}\" }})
460 |> result.is_ok()
461 |> should.be_true()"
462 );
463 }
464
465 let _ = writeln!(out, "}}");
466}
467
468#[allow(clippy::too_many_arguments)]
469fn render_test_case(
470 out: &mut String,
471 fixture: &Fixture,
472 e2e_config: &E2eConfig,
473 module_path: &str,
474 _function_name: &str,
475 _result_var: &str,
476 _args: &[crate::config::ArgMapping],
477 field_resolver: &FieldResolver,
478 enum_fields: &HashSet<String>,
479) {
480 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
482 let lang = "gleam";
483 let call_overrides = call_config.overrides.get(lang);
484 let function_name = call_overrides
485 .and_then(|o| o.function.as_ref())
486 .cloned()
487 .unwrap_or_else(|| call_config.function.clone());
488 let result_var = &call_config.result_var;
489 let args = &call_config.args;
490
491 let raw_name = sanitize_ident(&fixture.id);
496 let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
497 let test_name = if stripped.is_empty() {
498 raw_name.as_str()
499 } else {
500 stripped
501 };
502 let description = &fixture.description;
503 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
504
505 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
506
507 let _ = writeln!(out, "// {description}");
510 let _ = writeln!(out, "pub fn {test_name}_test() {{");
511
512 for line in &setup_lines {
513 let _ = writeln!(out, " {line}");
514 }
515
516 if expects_error {
517 let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
518 let _ = writeln!(out, "}}");
519 return;
520 }
521
522 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
523 let _ = writeln!(out, " {result_var} |> should.be_ok()");
524
525 for assertion in &fixture.assertions {
526 render_assertion(out, assertion, result_var, field_resolver, enum_fields);
527 }
528
529 let _ = writeln!(out, "}}");
530}
531
532fn build_args_and_setup(
534 input: &serde_json::Value,
535 args: &[crate::config::ArgMapping],
536 fixture_id: &str,
537) -> (Vec<String>, String) {
538 if args.is_empty() {
539 return (Vec::new(), String::new());
540 }
541
542 let mut setup_lines: Vec<String> = Vec::new();
543 let mut parts: Vec<String> = Vec::new();
544
545 for arg in args {
546 if arg.arg_type == "mock_url" {
547 setup_lines.push(format!(
548 "let {} = (import \"os\" as os).get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
549 arg.name,
550 ));
551 parts.push(arg.name.clone());
552 continue;
553 }
554
555 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
556 let val = input.get(field);
557 match val {
558 None | Some(serde_json::Value::Null) if arg.optional => {
559 continue;
560 }
561 None | Some(serde_json::Value::Null) => {
562 let default_val = match arg.arg_type.as_str() {
563 "string" => "\"\"".to_string(),
564 "int" | "integer" => "0".to_string(),
565 "float" | "number" => "0.0".to_string(),
566 "bool" | "boolean" => "False".to_string(),
567 _ => "Nil".to_string(),
568 };
569 parts.push(default_val);
570 }
571 Some(v) => {
572 parts.push(json_to_gleam(v));
573 }
574 }
575 }
576
577 (setup_lines, parts.join(", "))
578}
579
580fn render_assertion(
581 out: &mut String,
582 assertion: &Assertion,
583 result_var: &str,
584 field_resolver: &FieldResolver,
585 enum_fields: &HashSet<String>,
586) {
587 if let Some(f) = &assertion.field {
589 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
590 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
591 return;
592 }
593 }
594
595 let _field_is_enum = assertion
597 .field
598 .as_deref()
599 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
600
601 let field_expr = match &assertion.field {
602 Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
603 _ => result_var.to_string(),
604 };
605
606 match assertion.assertion_type.as_str() {
607 "equals" => {
608 if let Some(expected) = &assertion.value {
609 let gleam_val = json_to_gleam(expected);
610 let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
611 }
612 }
613 "contains" => {
614 if let Some(expected) = &assertion.value {
615 let gleam_val = json_to_gleam(expected);
616 let _ = writeln!(
617 out,
618 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
619 );
620 }
621 }
622 "contains_all" => {
623 if let Some(values) = &assertion.values {
624 for val in values {
625 let gleam_val = json_to_gleam(val);
626 let _ = writeln!(
627 out,
628 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
629 );
630 }
631 }
632 }
633 "not_contains" => {
634 if let Some(expected) = &assertion.value {
635 let gleam_val = json_to_gleam(expected);
636 let _ = writeln!(
637 out,
638 " {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
639 );
640 }
641 }
642 "not_empty" => {
643 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
644 }
645 "is_empty" => {
646 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
647 }
648 "starts_with" => {
649 if let Some(expected) = &assertion.value {
650 let gleam_val = json_to_gleam(expected);
651 let _ = writeln!(
652 out,
653 " {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
654 );
655 }
656 }
657 "ends_with" => {
658 if let Some(expected) = &assertion.value {
659 let gleam_val = json_to_gleam(expected);
660 let _ = writeln!(
661 out,
662 " {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
663 );
664 }
665 }
666 "min_length" => {
667 if let Some(val) = &assertion.value {
668 if let Some(n) = val.as_u64() {
669 let _ = writeln!(
670 out,
671 " {field_expr} |> string.length |> int.is_at_least({n}) |> should.equal(True)"
672 );
673 }
674 }
675 }
676 "max_length" => {
677 if let Some(val) = &assertion.value {
678 if let Some(n) = val.as_u64() {
679 let _ = writeln!(
680 out,
681 " {field_expr} |> string.length |> int.is_at_most({n}) |> should.equal(True)"
682 );
683 }
684 }
685 }
686 "count_min" => {
687 if let Some(val) = &assertion.value {
688 if let Some(n) = val.as_u64() {
689 let _ = writeln!(
690 out,
691 " {field_expr} |> list.length |> int.is_at_least({n}) |> should.equal(True)"
692 );
693 }
694 }
695 }
696 "count_equals" => {
697 if let Some(val) = &assertion.value {
698 if let Some(n) = val.as_u64() {
699 let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
700 }
701 }
702 }
703 "is_true" => {
704 let _ = writeln!(out, " {field_expr} |> should.equal(True)");
705 }
706 "is_false" => {
707 let _ = writeln!(out, " {field_expr} |> should.equal(False)");
708 }
709 "not_error" => {
710 }
712 "error" => {
713 }
715 "greater_than" => {
716 if let Some(val) = &assertion.value {
717 let gleam_val = json_to_gleam(val);
718 let _ = writeln!(
719 out,
720 " {field_expr} |> int.is_strictly_greater_than({gleam_val}) |> should.equal(True)"
721 );
722 }
723 }
724 "less_than" => {
725 if let Some(val) = &assertion.value {
726 let gleam_val = json_to_gleam(val);
727 let _ = writeln!(
728 out,
729 " {field_expr} |> int.is_strictly_less_than({gleam_val}) |> should.equal(True)"
730 );
731 }
732 }
733 "greater_than_or_equal" => {
734 if let Some(val) = &assertion.value {
735 let gleam_val = json_to_gleam(val);
736 let _ = writeln!(
737 out,
738 " {field_expr} |> int.is_at_least({gleam_val}) |> should.equal(True)"
739 );
740 }
741 }
742 "less_than_or_equal" => {
743 if let Some(val) = &assertion.value {
744 let gleam_val = json_to_gleam(val);
745 let _ = writeln!(
746 out,
747 " {field_expr} |> int.is_at_most({gleam_val}) |> should.equal(True)"
748 );
749 }
750 }
751 "contains_any" => {
752 if let Some(values) = &assertion.values {
753 for val in values {
754 let gleam_val = json_to_gleam(val);
755 let _ = writeln!(
756 out,
757 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
758 );
759 }
760 }
761 }
762 "matches_regex" => {
763 let _ = writeln!(out, " // regex match not yet implemented for Gleam");
764 }
765 "method_result" => {
766 let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
767 }
768 other => {
769 panic!("Gleam e2e generator: unsupported assertion type: {other}");
770 }
771 }
772}
773
774fn json_to_gleam(value: &serde_json::Value) -> String {
776 match value {
777 serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
778 serde_json::Value::Bool(b) => {
779 if *b {
780 "True".to_string()
781 } else {
782 "False".to_string()
783 }
784 }
785 serde_json::Value::Number(n) => n.to_string(),
786 serde_json::Value::Null => "Nil".to_string(),
787 serde_json::Value::Array(arr) => {
788 let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
789 format!("[{}]", items.join(", "))
790 }
791 serde_json::Value::Object(_) => {
792 let json_str = serde_json::to_string(value).unwrap_or_default();
793 format!("\"{}\"", escape_gleam(&json_str))
794 }
795 }
796}