1use crate::config::E2eConfig;
7use crate::escape::{escape_gleam, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use anyhow::Result;
14use heck::ToSnakeCase;
15use std::collections::HashSet;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19use super::E2eCodegen;
20
21pub struct GleamE2eCodegen;
23
24impl E2eCodegen for GleamE2eCodegen {
25 fn generate(
26 &self,
27 groups: &[FixtureGroup],
28 e2e_config: &E2eConfig,
29 alef_config: &AlefConfig,
30 ) -> Result<Vec<GeneratedFile>> {
31 let lang = self.language_name();
32 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
33
34 let mut files = Vec::new();
35
36 let call = &e2e_config.call;
38 let overrides = call.overrides.get(lang);
39 let module_path = overrides
40 .and_then(|o| o.module.as_ref())
41 .cloned()
42 .unwrap_or_else(|| call.module.clone());
43 let function_name = overrides
44 .and_then(|o| o.function.as_ref())
45 .cloned()
46 .unwrap_or_else(|| call.function.clone());
47 let result_var = &call.result_var;
48
49 let gleam_pkg = e2e_config.resolve_package("gleam");
51 let pkg_path = gleam_pkg
52 .as_ref()
53 .and_then(|p| p.path.as_ref())
54 .cloned()
55 .unwrap_or_else(|| "../../packages/gleam".to_string());
56 let pkg_name = gleam_pkg
57 .as_ref()
58 .and_then(|p| p.name.as_ref())
59 .cloned()
60 .unwrap_or_else(|| alef_config.crate_config.name.to_snake_case());
61
62 files.push(GeneratedFile {
64 path: output_base.join("gleam.toml"),
65 content: render_gleam_toml(&pkg_path, &pkg_name, e2e_config.dep_mode),
66 generated_header: false,
67 });
68
69 files.push(GeneratedFile {
72 path: output_base.join("src").join("e2e_gleam.gleam"),
73 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(),
74 generated_header: false,
75 });
76
77 let mut any_tests = false;
79
80 for group in groups {
82 let active: Vec<&Fixture> = group
83 .fixtures
84 .iter()
85 .filter(|f| !f.is_http_test())
89 .filter(|f| {
95 let call_cfg = e2e_config.resolve_call(f.call.as_deref());
96 call_cfg.overrides.contains_key(lang)
97 })
98 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
99 .collect();
100
101 if active.is_empty() {
102 continue;
103 }
104
105 let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
106 let field_resolver = FieldResolver::new(
107 &e2e_config.fields,
108 &e2e_config.fields_optional,
109 &e2e_config.result_fields,
110 &e2e_config.fields_array,
111 );
112 let content = render_test_file(
113 &group.category,
114 &active,
115 e2e_config,
116 &module_path,
117 &function_name,
118 result_var,
119 &e2e_config.call.args,
120 &field_resolver,
121 &e2e_config.fields_enum,
122 );
123 files.push(GeneratedFile {
124 path: output_base.join("test").join(filename),
125 content,
126 generated_header: true,
127 });
128 any_tests = true;
129 }
130
131 if !any_tests {
135 let smoke = concat!(
136 "// Generated by alef. Do not edit by hand.\n",
137 "// No fixture-driven tests for Gleam — binding only exports schema construction functions\n",
138 "// that require the Elixir NIF to be loaded at runtime. This smoke test verifies\n",
139 "// that the package compiles and is importable without executing NIF calls.\n",
140 "import gleeunit\n",
141 "import gleeunit/should\n",
142 "\n",
143 "pub fn main() {\n",
144 " gleeunit.main()\n",
145 "}\n",
146 "\n",
147 "pub fn compilation_smoke_test() {\n",
148 " // The Gleam binding compiles correctly and is a valid Erlang module.\n",
149 " // NIF-backed functions are not called here because they require the\n",
150 " // Elixir/Erlang NIF to be loaded at runtime.\n",
151 " True |> should.equal(True)\n",
152 "}\n",
153 ).to_string();
154 files.push(GeneratedFile {
155 path: output_base.join("test").join("e2e_gleam_test.gleam"),
156 content: smoke,
157 generated_header: false,
158 });
159 }
160
161 Ok(files)
162 }
163
164 fn language_name(&self) -> &'static str {
165 "gleam"
166 }
167}
168
169fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
174 use alef_core::template_versions::hex;
175 let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
176 let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
177 let deps = match dep_mode {
178 crate::config::DependencyMode::Registry => {
179 format!(
180 r#"{pkg_name} = ">= 0.1.0"
181gleam_stdlib = "{stdlib}"
182gleeunit = "{gleeunit}""#
183 )
184 }
185 crate::config::DependencyMode::Local => {
186 format!(
187 r#"{pkg_name} = {{ path = "{pkg_path}" }}
188gleam_stdlib = "{stdlib}"
189gleeunit = "{gleeunit}""#
190 )
191 }
192 };
193
194 format!(
195 r#"name = "e2e_gleam"
196version = "0.1.0"
197target = "erlang"
198
199[dependencies]
200{deps}
201"#
202 )
203}
204
205#[allow(clippy::too_many_arguments)]
206fn render_test_file(
207 _category: &str,
208 fixtures: &[&Fixture],
209 e2e_config: &E2eConfig,
210 module_path: &str,
211 function_name: &str,
212 result_var: &str,
213 args: &[crate::config::ArgMapping],
214 field_resolver: &FieldResolver,
215 enum_fields: &HashSet<String>,
216) -> String {
217 let mut out = String::new();
218 out.push_str(&hash::header(CommentStyle::DoubleSlash));
219 let _ = writeln!(out, "import gleeunit");
220 let _ = writeln!(out, "import gleeunit/should");
221 let _ = writeln!(out, "import {module_path}");
222 let _ = writeln!(out);
223
224 let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
226
227 for fixture in fixtures {
229 for assertion in &fixture.assertions {
230 match assertion.assertion_type.as_str() {
231 "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" | "min_length"
232 | "max_length" | "contains_any" => {
233 needed_modules.insert("string");
234 }
235 "not_empty" | "is_empty" | "count_min" | "count_equals" => {
236 needed_modules.insert("list");
237 }
238 "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
239 needed_modules.insert("int");
240 }
241 _ => {}
242 }
243 }
244 }
245
246 for module in &needed_modules {
248 let _ = writeln!(out, "import gleam/{module}");
249 }
250
251 if !needed_modules.is_empty() {
252 let _ = writeln!(out);
253 }
254
255 for fixture in fixtures {
257 render_test_case(
258 &mut out,
259 fixture,
260 e2e_config,
261 module_path,
262 function_name,
263 result_var,
264 args,
265 field_resolver,
266 enum_fields,
267 );
268 let _ = writeln!(out);
269 }
270
271 out
272}
273
274#[allow(clippy::too_many_arguments)]
275fn render_test_case(
276 out: &mut String,
277 fixture: &Fixture,
278 e2e_config: &E2eConfig,
279 module_path: &str,
280 _function_name: &str,
281 _result_var: &str,
282 _args: &[crate::config::ArgMapping],
283 field_resolver: &FieldResolver,
284 enum_fields: &HashSet<String>,
285) {
286 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
288 let lang = "gleam";
289 let call_overrides = call_config.overrides.get(lang);
290 let function_name = call_overrides
291 .and_then(|o| o.function.as_ref())
292 .cloned()
293 .unwrap_or_else(|| call_config.function.clone());
294 let result_var = &call_config.result_var;
295 let args = &call_config.args;
296
297 let raw_name = sanitize_ident(&fixture.id);
302 let stripped = raw_name.trim_start_matches(|c: char| c == '_' || c.is_ascii_digit());
303 let test_name = if stripped.is_empty() { raw_name.as_str() } else { stripped };
304 let description = &fixture.description;
305 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
306
307 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
308
309 let _ = writeln!(out, "// {description}");
312 let _ = writeln!(out, "pub fn {test_name}_test() {{");
313
314 for line in &setup_lines {
315 let _ = writeln!(out, " {line}");
316 }
317
318 if expects_error {
319 let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
320 let _ = writeln!(out, "}}");
321 return;
322 }
323
324 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
325 let _ = writeln!(out, " {result_var} |> should.be_ok()");
326
327 for assertion in &fixture.assertions {
328 render_assertion(out, assertion, result_var, field_resolver, enum_fields);
329 }
330
331 let _ = writeln!(out, "}}");
332}
333
334fn build_args_and_setup(
336 input: &serde_json::Value,
337 args: &[crate::config::ArgMapping],
338 fixture_id: &str,
339) -> (Vec<String>, String) {
340 if args.is_empty() {
341 return (Vec::new(), String::new());
342 }
343
344 let mut setup_lines: Vec<String> = Vec::new();
345 let mut parts: Vec<String> = Vec::new();
346
347 for arg in args {
348 if arg.arg_type == "mock_url" {
349 setup_lines.push(format!(
350 "let {} = (import \"os\" as os).get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
351 arg.name,
352 ));
353 parts.push(arg.name.clone());
354 continue;
355 }
356
357 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
358 let val = input.get(field);
359 match val {
360 None | Some(serde_json::Value::Null) if arg.optional => {
361 continue;
362 }
363 None | Some(serde_json::Value::Null) => {
364 let default_val = match arg.arg_type.as_str() {
365 "string" => "\"\"".to_string(),
366 "int" | "integer" => "0".to_string(),
367 "float" | "number" => "0.0".to_string(),
368 "bool" | "boolean" => "False".to_string(),
369 _ => "Nil".to_string(),
370 };
371 parts.push(default_val);
372 }
373 Some(v) => {
374 parts.push(json_to_gleam(v));
375 }
376 }
377 }
378
379 (setup_lines, parts.join(", "))
380}
381
382fn render_assertion(
383 out: &mut String,
384 assertion: &Assertion,
385 result_var: &str,
386 field_resolver: &FieldResolver,
387 enum_fields: &HashSet<String>,
388) {
389 if let Some(f) = &assertion.field {
391 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
392 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
393 return;
394 }
395 }
396
397 let _field_is_enum = assertion
399 .field
400 .as_deref()
401 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
402
403 let field_expr = match &assertion.field {
404 Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
405 _ => result_var.to_string(),
406 };
407
408 match assertion.assertion_type.as_str() {
409 "equals" => {
410 if let Some(expected) = &assertion.value {
411 let gleam_val = json_to_gleam(expected);
412 let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
413 }
414 }
415 "contains" => {
416 if let Some(expected) = &assertion.value {
417 let gleam_val = json_to_gleam(expected);
418 let _ = writeln!(
419 out,
420 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
421 );
422 }
423 }
424 "contains_all" => {
425 if let Some(values) = &assertion.values {
426 for val in values {
427 let gleam_val = json_to_gleam(val);
428 let _ = writeln!(
429 out,
430 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
431 );
432 }
433 }
434 }
435 "not_contains" => {
436 if let Some(expected) = &assertion.value {
437 let gleam_val = json_to_gleam(expected);
438 let _ = writeln!(
439 out,
440 " {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
441 );
442 }
443 }
444 "not_empty" => {
445 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
446 }
447 "is_empty" => {
448 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
449 }
450 "starts_with" => {
451 if let Some(expected) = &assertion.value {
452 let gleam_val = json_to_gleam(expected);
453 let _ = writeln!(
454 out,
455 " {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
456 );
457 }
458 }
459 "ends_with" => {
460 if let Some(expected) = &assertion.value {
461 let gleam_val = json_to_gleam(expected);
462 let _ = writeln!(
463 out,
464 " {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
465 );
466 }
467 }
468 "min_length" => {
469 if let Some(val) = &assertion.value {
470 if let Some(n) = val.as_u64() {
471 let _ = writeln!(
472 out,
473 " {field_expr} |> string.length |> int.is_at_least({n}) |> should.equal(True)"
474 );
475 }
476 }
477 }
478 "max_length" => {
479 if let Some(val) = &assertion.value {
480 if let Some(n) = val.as_u64() {
481 let _ = writeln!(
482 out,
483 " {field_expr} |> string.length |> int.is_at_most({n}) |> should.equal(True)"
484 );
485 }
486 }
487 }
488 "count_min" => {
489 if let Some(val) = &assertion.value {
490 if let Some(n) = val.as_u64() {
491 let _ = writeln!(
492 out,
493 " {field_expr} |> list.length |> int.is_at_least({n}) |> should.equal(True)"
494 );
495 }
496 }
497 }
498 "count_equals" => {
499 if let Some(val) = &assertion.value {
500 if let Some(n) = val.as_u64() {
501 let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
502 }
503 }
504 }
505 "is_true" => {
506 let _ = writeln!(out, " {field_expr} |> should.equal(True)");
507 }
508 "is_false" => {
509 let _ = writeln!(out, " {field_expr} |> should.equal(False)");
510 }
511 "not_error" => {
512 }
514 "error" => {
515 }
517 "greater_than" => {
518 if let Some(val) = &assertion.value {
519 let gleam_val = json_to_gleam(val);
520 let _ = writeln!(
521 out,
522 " {field_expr} |> int.is_strictly_greater_than({gleam_val}) |> should.equal(True)"
523 );
524 }
525 }
526 "less_than" => {
527 if let Some(val) = &assertion.value {
528 let gleam_val = json_to_gleam(val);
529 let _ = writeln!(
530 out,
531 " {field_expr} |> int.is_strictly_less_than({gleam_val}) |> should.equal(True)"
532 );
533 }
534 }
535 "greater_than_or_equal" => {
536 if let Some(val) = &assertion.value {
537 let gleam_val = json_to_gleam(val);
538 let _ = writeln!(
539 out,
540 " {field_expr} |> int.is_at_least({gleam_val}) |> should.equal(True)"
541 );
542 }
543 }
544 "less_than_or_equal" => {
545 if let Some(val) = &assertion.value {
546 let gleam_val = json_to_gleam(val);
547 let _ = writeln!(
548 out,
549 " {field_expr} |> int.is_at_most({gleam_val}) |> should.equal(True)"
550 );
551 }
552 }
553 "contains_any" => {
554 if let Some(values) = &assertion.values {
555 for val in values {
556 let gleam_val = json_to_gleam(val);
557 let _ = writeln!(
558 out,
559 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
560 );
561 }
562 }
563 }
564 "matches_regex" => {
565 let _ = writeln!(out, " // regex match not yet implemented for Gleam");
566 }
567 "method_result" => {
568 let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
569 }
570 other => {
571 panic!("Gleam e2e generator: unsupported assertion type: {other}");
572 }
573 }
574}
575
576fn json_to_gleam(value: &serde_json::Value) -> String {
578 match value {
579 serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
580 serde_json::Value::Bool(b) => {
581 if *b {
582 "True".to_string()
583 } else {
584 "False".to_string()
585 }
586 }
587 serde_json::Value::Number(n) => n.to_string(),
588 serde_json::Value::Null => "Nil".to_string(),
589 serde_json::Value::Array(arr) => {
590 let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
591 format!("[{}]", items.join(", "))
592 }
593 serde_json::Value::Object(_) => {
594 let json_str = serde_json::to_string(value).unwrap_or_default();
595 format!("\"{}\"", escape_gleam(&json_str))
596 }
597 }
598}