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| {
91 if f.is_http_test() {
92 true
93 } else {
94 let call_cfg = e2e_config.resolve_call(f.call.as_deref());
95 call_cfg.overrides.contains_key(lang)
96 }
97 })
98 .collect();
99
100 if active.is_empty() {
101 continue;
102 }
103
104 let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
105 let field_resolver = FieldResolver::new(
106 &e2e_config.fields,
107 &e2e_config.fields_optional,
108 &e2e_config.result_fields,
109 &e2e_config.fields_array,
110 );
111 let content = render_test_file(
112 &group.category,
113 &active,
114 e2e_config,
115 &module_path,
116 &function_name,
117 result_var,
118 &e2e_config.call.args,
119 &field_resolver,
120 &e2e_config.fields_enum,
121 );
122 files.push(GeneratedFile {
123 path: output_base.join("test").join(filename),
124 content,
125 generated_header: true,
126 });
127 any_tests = true;
128 }
129
130 if !any_tests {
132 let smoke = concat!(
133 "// Generated by alef. Do not edit by hand.\n",
134 "// No fixture-driven tests for Gleam — e2e tests require HTTP fixtures\n",
135 "// or non-HTTP fixtures with gleam-specific call overrides.\n",
136 "import gleeunit\n",
137 "import gleeunit/should\n",
138 "\n",
139 "pub fn main() {\n",
140 " gleeunit.main()\n",
141 "}\n",
142 "\n",
143 "pub fn compilation_smoke_test() {\n",
144 " True |> should.equal(True)\n",
145 "}\n",
146 )
147 .to_string();
148 files.push(GeneratedFile {
149 path: output_base.join("test").join("e2e_gleam_test.gleam"),
150 content: smoke,
151 generated_header: false,
152 });
153 }
154
155 Ok(files)
156 }
157
158 fn language_name(&self) -> &'static str {
159 "gleam"
160 }
161}
162
163fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
168 use alef_core::template_versions::hex;
169 let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
170 let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
171 let gleam_httpc = hex::GLEAM_HTTPC_VERSION_RANGE;
172 let deps = match dep_mode {
173 crate::config::DependencyMode::Registry => {
174 format!(
175 r#"{pkg_name} = ">= 0.1.0"
176gleam_stdlib = "{stdlib}"
177gleeunit = "{gleeunit}"
178gleam_httpc = "{gleam_httpc}""#
179 )
180 }
181 crate::config::DependencyMode::Local => {
182 format!(
183 r#"{pkg_name} = {{ path = "{pkg_path}" }}
184gleam_stdlib = "{stdlib}"
185gleeunit = "{gleeunit}"
186gleam_httpc = "{gleam_httpc}""#
187 )
188 }
189 };
190
191 format!(
192 r#"name = "e2e_gleam"
193version = "0.1.0"
194target = "erlang"
195
196[dependencies]
197{deps}
198"#
199 )
200}
201
202#[allow(clippy::too_many_arguments)]
203fn render_test_file(
204 _category: &str,
205 fixtures: &[&Fixture],
206 e2e_config: &E2eConfig,
207 module_path: &str,
208 function_name: &str,
209 result_var: &str,
210 args: &[crate::config::ArgMapping],
211 field_resolver: &FieldResolver,
212 enum_fields: &HashSet<String>,
213) -> String {
214 let mut out = String::new();
215 out.push_str(&hash::header(CommentStyle::DoubleSlash));
216 let _ = writeln!(out, "import gleeunit");
217 let _ = writeln!(out, "import gleeunit/should");
218
219 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
221
222 if has_http_fixtures {
224 let _ = writeln!(out, "import gleam_httpc");
225 let _ = writeln!(out, "import gleam/http");
226 let _ = writeln!(out, "import gleam/http/request");
227 let _ = writeln!(out, "import gleam/list");
228 let _ = writeln!(out, "import gleam/string");
229 let _ = writeln!(out, "import gleam/os");
230 }
231
232 let has_non_http_with_override = fixtures.iter().any(|f| !f.is_http_test());
234 if has_non_http_with_override {
235 let _ = writeln!(out, "import {module_path}");
236 }
237 let _ = writeln!(out);
238
239 let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
241
242 for fixture in fixtures {
244 if fixture.is_http_test() {
245 continue; }
247 for assertion in &fixture.assertions {
248 match assertion.assertion_type.as_str() {
249 "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" | "min_length"
250 | "max_length" | "contains_any" => {
251 needed_modules.insert("string");
252 }
253 "not_empty" | "is_empty" | "count_min" | "count_equals" => {
254 needed_modules.insert("list");
255 }
256 "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
257 needed_modules.insert("int");
258 }
259 _ => {}
260 }
261 }
262 }
263
264 for module in &needed_modules {
266 let _ = writeln!(out, "import gleam/{module}");
267 }
268
269 if !needed_modules.is_empty() {
270 let _ = writeln!(out);
271 }
272
273 for fixture in fixtures {
275 if fixture.is_http_test() {
276 render_http_test_case(&mut out, fixture);
277 } else {
278 render_test_case(
279 &mut out,
280 fixture,
281 e2e_config,
282 module_path,
283 function_name,
284 result_var,
285 args,
286 field_resolver,
287 enum_fields,
288 );
289 }
290 let _ = writeln!(out);
291 }
292
293 out
294}
295
296fn render_http_test_case(out: &mut String, fixture: &Fixture) {
301 let http = fixture.http.as_ref().unwrap();
302 let description = &fixture.description;
303 let request = &http.request;
304 let expected = &http.expected_response;
305 let method = request.method.to_uppercase();
306 let fixture_id = &fixture.id;
307 let expected_status = expected.status_code;
308 let test_name = sanitize_ident(&fixture.id);
309
310 let _ = writeln!(out, "// {description}");
311 let _ = writeln!(out, "pub fn {test_name}_test() {{");
312
313 let _ = writeln!(out, " let base_url = case os.get_env(\"MOCK_SERVER_URL\") {{");
315 let _ = writeln!(out, " Ok(u) -> u");
316 let _ = writeln!(out, " Error(_) -> \"http://localhost:8080\"");
317 let _ = writeln!(out, " }}");
318
319 let _ = writeln!(
321 out,
322 " let assert Ok(req) = request.to(base_url <> \"/fixtures/{fixture_id}\")"
323 );
324
325 let method_const = match method.as_str() {
327 "GET" => "Get",
328 "POST" => "Post",
329 "PUT" => "Put",
330 "DELETE" => "Delete",
331 "PATCH" => "Patch",
332 "HEAD" => "Head",
333 "OPTIONS" => "Options",
334 _ => "Post",
335 };
336 let _ = writeln!(out, " let req = request.set_method(req, http.{method_const})");
337
338 let content_type = request.content_type.as_deref().unwrap_or("application/json");
340 if request.body.is_some() {
341 let _ = writeln!(
342 out,
343 " let req = request.set_header(req, \"content-type\", \"{content_type}\")"
344 );
345 }
346 for (name, value) in &request.headers {
347 let lower_name = name.to_lowercase();
349 if matches!(lower_name.as_str(), "content-length" | "host" | "transfer-encoding") {
350 continue;
351 }
352 let escaped_name = escape_gleam(name);
353 let escaped_value = escape_gleam(value);
354 let _ = writeln!(
355 out,
356 " let req = request.set_header(req, \"{escaped_name}\", \"{escaped_value}\")"
357 );
358 }
359
360 if !request.cookies.is_empty() {
362 let cookie_str: Vec<String> = request.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
363 let cookie_header = escape_gleam(&cookie_str.join("; "));
364 let _ = writeln!(
365 out,
366 " let req = request.set_header(req, \"cookie\", \"{cookie_header}\")"
367 );
368 }
369
370 if let Some(body) = &request.body {
372 let json_str = serde_json::to_string(body).unwrap_or_default();
373 let escaped = escape_gleam(&json_str);
374 let _ = writeln!(out, " let req = request.set_body(req, \"{escaped}\")");
375 }
376
377 let _ = writeln!(out, " let assert Ok(resp) = gleam_httpc.send(req)");
379
380 let _ = writeln!(out, " resp.status |> should.equal({expected_status})");
382
383 if let Some(expected_body) = &expected.body {
385 match expected_body {
386 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
387 let json_str = serde_json::to_string(expected_body).unwrap_or_default();
388 let escaped = escape_gleam(&json_str);
389 let _ = writeln!(out, " resp.body |> string.trim |> should.equal(\"{escaped}\")");
390 }
391 serde_json::Value::String(s) => {
392 let escaped = escape_gleam(s);
393 let _ = writeln!(out, " resp.body |> string.trim |> should.equal(\"{escaped}\")");
394 }
395 other => {
396 let escaped = escape_gleam(&other.to_string());
397 let _ = writeln!(out, " resp.body |> string.trim |> should.equal(\"{escaped}\")");
398 }
399 }
400 }
401
402 for (name, value) in &expected.headers {
404 if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
405 continue;
406 }
407 if name.to_lowercase() == "content-encoding" {
410 continue;
411 }
412 let escaped_name = escape_gleam(&name.to_lowercase());
413 let _escaped_value = escape_gleam(value);
414 let _ = writeln!(
415 out,
416 " resp.headers
417 |> list.find(fn(h) {{ h.0 == \"{escaped_name}\" }})
418 |> option.is_some()
419 |> should.be_true()"
420 );
421 }
422
423 let _ = writeln!(out, "}}");
424}
425
426#[allow(clippy::too_many_arguments)]
427fn render_test_case(
428 out: &mut String,
429 fixture: &Fixture,
430 e2e_config: &E2eConfig,
431 module_path: &str,
432 _function_name: &str,
433 _result_var: &str,
434 _args: &[crate::config::ArgMapping],
435 field_resolver: &FieldResolver,
436 enum_fields: &HashSet<String>,
437) {
438 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
440 let lang = "gleam";
441 let call_overrides = call_config.overrides.get(lang);
442 let function_name = call_overrides
443 .and_then(|o| o.function.as_ref())
444 .cloned()
445 .unwrap_or_else(|| call_config.function.clone());
446 let result_var = &call_config.result_var;
447 let args = &call_config.args;
448
449 let raw_name = sanitize_ident(&fixture.id);
454 let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
455 let test_name = if stripped.is_empty() {
456 raw_name.as_str()
457 } else {
458 stripped
459 };
460 let description = &fixture.description;
461 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
462
463 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
464
465 let _ = writeln!(out, "// {description}");
468 let _ = writeln!(out, "pub fn {test_name}_test() {{");
469
470 for line in &setup_lines {
471 let _ = writeln!(out, " {line}");
472 }
473
474 if expects_error {
475 let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
476 let _ = writeln!(out, "}}");
477 return;
478 }
479
480 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
481 let _ = writeln!(out, " {result_var} |> should.be_ok()");
482
483 for assertion in &fixture.assertions {
484 render_assertion(out, assertion, result_var, field_resolver, enum_fields);
485 }
486
487 let _ = writeln!(out, "}}");
488}
489
490fn build_args_and_setup(
492 input: &serde_json::Value,
493 args: &[crate::config::ArgMapping],
494 fixture_id: &str,
495) -> (Vec<String>, String) {
496 if args.is_empty() {
497 return (Vec::new(), String::new());
498 }
499
500 let mut setup_lines: Vec<String> = Vec::new();
501 let mut parts: Vec<String> = Vec::new();
502
503 for arg in args {
504 if arg.arg_type == "mock_url" {
505 setup_lines.push(format!(
506 "let {} = (import \"os\" as os).get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
507 arg.name,
508 ));
509 parts.push(arg.name.clone());
510 continue;
511 }
512
513 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
514 let val = input.get(field);
515 match val {
516 None | Some(serde_json::Value::Null) if arg.optional => {
517 continue;
518 }
519 None | Some(serde_json::Value::Null) => {
520 let default_val = match arg.arg_type.as_str() {
521 "string" => "\"\"".to_string(),
522 "int" | "integer" => "0".to_string(),
523 "float" | "number" => "0.0".to_string(),
524 "bool" | "boolean" => "False".to_string(),
525 _ => "Nil".to_string(),
526 };
527 parts.push(default_val);
528 }
529 Some(v) => {
530 parts.push(json_to_gleam(v));
531 }
532 }
533 }
534
535 (setup_lines, parts.join(", "))
536}
537
538fn render_assertion(
539 out: &mut String,
540 assertion: &Assertion,
541 result_var: &str,
542 field_resolver: &FieldResolver,
543 enum_fields: &HashSet<String>,
544) {
545 if let Some(f) = &assertion.field {
547 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
548 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
549 return;
550 }
551 }
552
553 let _field_is_enum = assertion
555 .field
556 .as_deref()
557 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
558
559 let field_expr = match &assertion.field {
560 Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
561 _ => result_var.to_string(),
562 };
563
564 match assertion.assertion_type.as_str() {
565 "equals" => {
566 if let Some(expected) = &assertion.value {
567 let gleam_val = json_to_gleam(expected);
568 let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
569 }
570 }
571 "contains" => {
572 if let Some(expected) = &assertion.value {
573 let gleam_val = json_to_gleam(expected);
574 let _ = writeln!(
575 out,
576 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
577 );
578 }
579 }
580 "contains_all" => {
581 if let Some(values) = &assertion.values {
582 for val in values {
583 let gleam_val = json_to_gleam(val);
584 let _ = writeln!(
585 out,
586 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
587 );
588 }
589 }
590 }
591 "not_contains" => {
592 if let Some(expected) = &assertion.value {
593 let gleam_val = json_to_gleam(expected);
594 let _ = writeln!(
595 out,
596 " {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
597 );
598 }
599 }
600 "not_empty" => {
601 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
602 }
603 "is_empty" => {
604 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
605 }
606 "starts_with" => {
607 if let Some(expected) = &assertion.value {
608 let gleam_val = json_to_gleam(expected);
609 let _ = writeln!(
610 out,
611 " {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
612 );
613 }
614 }
615 "ends_with" => {
616 if let Some(expected) = &assertion.value {
617 let gleam_val = json_to_gleam(expected);
618 let _ = writeln!(
619 out,
620 " {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
621 );
622 }
623 }
624 "min_length" => {
625 if let Some(val) = &assertion.value {
626 if let Some(n) = val.as_u64() {
627 let _ = writeln!(
628 out,
629 " {field_expr} |> string.length |> int.is_at_least({n}) |> should.equal(True)"
630 );
631 }
632 }
633 }
634 "max_length" => {
635 if let Some(val) = &assertion.value {
636 if let Some(n) = val.as_u64() {
637 let _ = writeln!(
638 out,
639 " {field_expr} |> string.length |> int.is_at_most({n}) |> should.equal(True)"
640 );
641 }
642 }
643 }
644 "count_min" => {
645 if let Some(val) = &assertion.value {
646 if let Some(n) = val.as_u64() {
647 let _ = writeln!(
648 out,
649 " {field_expr} |> list.length |> int.is_at_least({n}) |> should.equal(True)"
650 );
651 }
652 }
653 }
654 "count_equals" => {
655 if let Some(val) = &assertion.value {
656 if let Some(n) = val.as_u64() {
657 let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
658 }
659 }
660 }
661 "is_true" => {
662 let _ = writeln!(out, " {field_expr} |> should.equal(True)");
663 }
664 "is_false" => {
665 let _ = writeln!(out, " {field_expr} |> should.equal(False)");
666 }
667 "not_error" => {
668 }
670 "error" => {
671 }
673 "greater_than" => {
674 if let Some(val) = &assertion.value {
675 let gleam_val = json_to_gleam(val);
676 let _ = writeln!(
677 out,
678 " {field_expr} |> int.is_strictly_greater_than({gleam_val}) |> should.equal(True)"
679 );
680 }
681 }
682 "less_than" => {
683 if let Some(val) = &assertion.value {
684 let gleam_val = json_to_gleam(val);
685 let _ = writeln!(
686 out,
687 " {field_expr} |> int.is_strictly_less_than({gleam_val}) |> should.equal(True)"
688 );
689 }
690 }
691 "greater_than_or_equal" => {
692 if let Some(val) = &assertion.value {
693 let gleam_val = json_to_gleam(val);
694 let _ = writeln!(
695 out,
696 " {field_expr} |> int.is_at_least({gleam_val}) |> should.equal(True)"
697 );
698 }
699 }
700 "less_than_or_equal" => {
701 if let Some(val) = &assertion.value {
702 let gleam_val = json_to_gleam(val);
703 let _ = writeln!(
704 out,
705 " {field_expr} |> int.is_at_most({gleam_val}) |> should.equal(True)"
706 );
707 }
708 }
709 "contains_any" => {
710 if let Some(values) = &assertion.values {
711 for val in values {
712 let gleam_val = json_to_gleam(val);
713 let _ = writeln!(
714 out,
715 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
716 );
717 }
718 }
719 }
720 "matches_regex" => {
721 let _ = writeln!(out, " // regex match not yet implemented for Gleam");
722 }
723 "method_result" => {
724 let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
725 }
726 other => {
727 panic!("Gleam e2e generator: unsupported assertion type: {other}");
728 }
729 }
730}
731
732fn json_to_gleam(value: &serde_json::Value) -> String {
734 match value {
735 serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
736 serde_json::Value::Bool(b) => {
737 if *b {
738 "True".to_string()
739 } else {
740 "False".to_string()
741 }
742 }
743 serde_json::Value::Number(n) => n.to_string(),
744 serde_json::Value::Null => "Nil".to_string(),
745 serde_json::Value::Array(arr) => {
746 let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
747 format!("[{}]", items.join(", "))
748 }
749 serde_json::Value::Object(_) => {
750 let json_str = serde_json::to_string(value).unwrap_or_default();
751 format!("\"{}\"", escape_gleam(&json_str))
752 }
753 }
754}