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 for group in groups {
71 let active: Vec<&Fixture> = group
72 .fixtures
73 .iter()
74 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
75 .collect();
76
77 if active.is_empty() {
78 continue;
79 }
80
81 let filename = format!("{}_test.gleam", sanitize_filename(&group.category));
82 let field_resolver = FieldResolver::new(
83 &e2e_config.fields,
84 &e2e_config.fields_optional,
85 &e2e_config.result_fields,
86 &e2e_config.fields_array,
87 );
88 let content = render_test_file(
89 &group.category,
90 &active,
91 e2e_config,
92 &module_path,
93 &function_name,
94 result_var,
95 &e2e_config.call.args,
96 &field_resolver,
97 &e2e_config.fields_enum,
98 );
99 files.push(GeneratedFile {
100 path: output_base.join("test").join(filename),
101 content,
102 generated_header: true,
103 });
104 }
105
106 Ok(files)
107 }
108
109 fn language_name(&self) -> &'static str {
110 "gleam"
111 }
112}
113
114fn render_gleam_toml(pkg_path: &str, pkg_name: &str, dep_mode: crate::config::DependencyMode) -> String {
119 use alef_core::template_versions::hex;
120 let stdlib = hex::GLEAM_STDLIB_VERSION_RANGE;
121 let gleeunit = hex::GLEEUNIT_VERSION_RANGE;
122 let deps = match dep_mode {
123 crate::config::DependencyMode::Registry => {
124 format!(
125 r#"{pkg_name} = ">= 0.1.0"
126gleam_stdlib = "{stdlib}"
127gleeunit = "{gleeunit}""#
128 )
129 }
130 crate::config::DependencyMode::Local => {
131 format!(
132 r#"{pkg_name} = {{ path = "{pkg_path}" }}
133gleam_stdlib = "{stdlib}"
134gleeunit = "{gleeunit}""#
135 )
136 }
137 };
138
139 format!(
140 r#"name = "e2e_gleam"
141version = "0.1.0"
142target = "erlang"
143
144[dependencies]
145{deps}
146"#
147 )
148}
149
150#[allow(clippy::too_many_arguments)]
151fn render_test_file(
152 _category: &str,
153 fixtures: &[&Fixture],
154 e2e_config: &E2eConfig,
155 module_path: &str,
156 function_name: &str,
157 result_var: &str,
158 args: &[crate::config::ArgMapping],
159 field_resolver: &FieldResolver,
160 enum_fields: &HashSet<String>,
161) -> String {
162 let mut out = String::new();
163 out.push_str(&hash::header(CommentStyle::DoubleSlash));
164 let _ = writeln!(out, "import gleeunit");
165 let _ = writeln!(out, "import gleeunit/should");
166 let _ = writeln!(out, "import {module_path}");
167 let _ = writeln!(out);
168
169 let mut needed_modules: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
171
172 for fixture in fixtures {
174 for assertion in &fixture.assertions {
175 match assertion.assertion_type.as_str() {
176 "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with" | "min_length"
177 | "max_length" | "contains_any" => {
178 needed_modules.insert("string");
179 }
180 "not_empty" | "is_empty" | "count_min" | "count_equals" => {
181 needed_modules.insert("list");
182 }
183 "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
184 needed_modules.insert("int");
185 }
186 _ => {}
187 }
188 }
189 }
190
191 for module in &needed_modules {
193 let _ = writeln!(out, "import gleam/{module}");
194 }
195
196 if !needed_modules.is_empty() {
197 let _ = writeln!(out);
198 }
199
200 for fixture in fixtures {
202 render_test_case(
203 &mut out,
204 fixture,
205 e2e_config,
206 module_path,
207 function_name,
208 result_var,
209 args,
210 field_resolver,
211 enum_fields,
212 );
213 let _ = writeln!(out);
214 }
215
216 out
217}
218
219#[allow(clippy::too_many_arguments)]
220fn render_test_case(
221 out: &mut String,
222 fixture: &Fixture,
223 e2e_config: &E2eConfig,
224 module_path: &str,
225 _function_name: &str,
226 _result_var: &str,
227 _args: &[crate::config::ArgMapping],
228 field_resolver: &FieldResolver,
229 enum_fields: &HashSet<String>,
230) {
231 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
233 let lang = "gleam";
234 let call_overrides = call_config.overrides.get(lang);
235 let function_name = call_overrides
236 .and_then(|o| o.function.as_ref())
237 .cloned()
238 .unwrap_or_else(|| call_config.function.clone());
239 let result_var = &call_config.result_var;
240 let args = &call_config.args;
241
242 let test_name = sanitize_ident(&fixture.id);
243 let description = &fixture.description;
244 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
245
246 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
247
248 let _ = writeln!(out, "// {description}");
251 let _ = writeln!(out, "pub fn {test_name}_test() {{");
252
253 for line in &setup_lines {
254 let _ = writeln!(out, " {line}");
255 }
256
257 if expects_error {
258 let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
259 let _ = writeln!(out, "}}");
260 return;
261 }
262
263 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
264 let _ = writeln!(out, " {result_var} |> should.be_ok()");
265
266 for assertion in &fixture.assertions {
267 render_assertion(out, assertion, result_var, field_resolver, enum_fields);
268 }
269
270 let _ = writeln!(out, "}}");
271}
272
273fn build_args_and_setup(
275 input: &serde_json::Value,
276 args: &[crate::config::ArgMapping],
277 fixture_id: &str,
278) -> (Vec<String>, String) {
279 if args.is_empty() {
280 return (Vec::new(), String::new());
281 }
282
283 let mut setup_lines: Vec<String> = Vec::new();
284 let mut parts: Vec<String> = Vec::new();
285
286 for arg in args {
287 if arg.arg_type == "mock_url" {
288 setup_lines.push(format!(
289 "let {} = (import \"os\" as os).get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
290 arg.name,
291 ));
292 parts.push(arg.name.clone());
293 continue;
294 }
295
296 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
297 let val = input.get(field);
298 match val {
299 None | Some(serde_json::Value::Null) if arg.optional => {
300 continue;
301 }
302 None | Some(serde_json::Value::Null) => {
303 let default_val = match arg.arg_type.as_str() {
304 "string" => "\"\"".to_string(),
305 "int" | "integer" => "0".to_string(),
306 "float" | "number" => "0.0".to_string(),
307 "bool" | "boolean" => "False".to_string(),
308 _ => "Nil".to_string(),
309 };
310 parts.push(default_val);
311 }
312 Some(v) => {
313 parts.push(json_to_gleam(v));
314 }
315 }
316 }
317
318 (setup_lines, parts.join(", "))
319}
320
321fn render_assertion(
322 out: &mut String,
323 assertion: &Assertion,
324 result_var: &str,
325 field_resolver: &FieldResolver,
326 enum_fields: &HashSet<String>,
327) {
328 if let Some(f) = &assertion.field {
330 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
331 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
332 return;
333 }
334 }
335
336 let _field_is_enum = assertion
338 .field
339 .as_deref()
340 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
341
342 let field_expr = match &assertion.field {
343 Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
344 _ => result_var.to_string(),
345 };
346
347 match assertion.assertion_type.as_str() {
348 "equals" => {
349 if let Some(expected) = &assertion.value {
350 let gleam_val = json_to_gleam(expected);
351 let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
352 }
353 }
354 "contains" => {
355 if let Some(expected) = &assertion.value {
356 let gleam_val = json_to_gleam(expected);
357 let _ = writeln!(
358 out,
359 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
360 );
361 }
362 }
363 "contains_all" => {
364 if let Some(values) = &assertion.values {
365 for val in values {
366 let gleam_val = json_to_gleam(val);
367 let _ = writeln!(
368 out,
369 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
370 );
371 }
372 }
373 }
374 "not_contains" => {
375 if let Some(expected) = &assertion.value {
376 let gleam_val = json_to_gleam(expected);
377 let _ = writeln!(
378 out,
379 " {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
380 );
381 }
382 }
383 "not_empty" => {
384 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
385 }
386 "is_empty" => {
387 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
388 }
389 "starts_with" => {
390 if let Some(expected) = &assertion.value {
391 let gleam_val = json_to_gleam(expected);
392 let _ = writeln!(
393 out,
394 " {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
395 );
396 }
397 }
398 "ends_with" => {
399 if let Some(expected) = &assertion.value {
400 let gleam_val = json_to_gleam(expected);
401 let _ = writeln!(
402 out,
403 " {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
404 );
405 }
406 }
407 "min_length" => {
408 if let Some(val) = &assertion.value {
409 if let Some(n) = val.as_u64() {
410 let _ = writeln!(
411 out,
412 " {field_expr} |> string.length |> int.is_at_least({n}) |> should.equal(True)"
413 );
414 }
415 }
416 }
417 "max_length" => {
418 if let Some(val) = &assertion.value {
419 if let Some(n) = val.as_u64() {
420 let _ = writeln!(
421 out,
422 " {field_expr} |> string.length |> int.is_at_most({n}) |> should.equal(True)"
423 );
424 }
425 }
426 }
427 "count_min" => {
428 if let Some(val) = &assertion.value {
429 if let Some(n) = val.as_u64() {
430 let _ = writeln!(
431 out,
432 " {field_expr} |> list.length |> int.is_at_least({n}) |> should.equal(True)"
433 );
434 }
435 }
436 }
437 "count_equals" => {
438 if let Some(val) = &assertion.value {
439 if let Some(n) = val.as_u64() {
440 let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
441 }
442 }
443 }
444 "is_true" => {
445 let _ = writeln!(out, " {field_expr} |> should.equal(True)");
446 }
447 "is_false" => {
448 let _ = writeln!(out, " {field_expr} |> should.equal(False)");
449 }
450 "not_error" => {
451 }
453 "error" => {
454 }
456 "greater_than" => {
457 if let Some(val) = &assertion.value {
458 let gleam_val = json_to_gleam(val);
459 let _ = writeln!(
460 out,
461 " {field_expr} |> int.is_strictly_greater_than({gleam_val}) |> should.equal(True)"
462 );
463 }
464 }
465 "less_than" => {
466 if let Some(val) = &assertion.value {
467 let gleam_val = json_to_gleam(val);
468 let _ = writeln!(
469 out,
470 " {field_expr} |> int.is_strictly_less_than({gleam_val}) |> should.equal(True)"
471 );
472 }
473 }
474 "greater_than_or_equal" => {
475 if let Some(val) = &assertion.value {
476 let gleam_val = json_to_gleam(val);
477 let _ = writeln!(
478 out,
479 " {field_expr} |> int.is_at_least({gleam_val}) |> should.equal(True)"
480 );
481 }
482 }
483 "less_than_or_equal" => {
484 if let Some(val) = &assertion.value {
485 let gleam_val = json_to_gleam(val);
486 let _ = writeln!(
487 out,
488 " {field_expr} |> int.is_at_most({gleam_val}) |> should.equal(True)"
489 );
490 }
491 }
492 "contains_any" => {
493 if let Some(values) = &assertion.values {
494 for val in values {
495 let gleam_val = json_to_gleam(val);
496 let _ = writeln!(
497 out,
498 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
499 );
500 }
501 }
502 }
503 "matches_regex" => {
504 let _ = writeln!(out, " // regex match not yet implemented for Gleam");
505 }
506 "method_result" => {
507 let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
508 }
509 other => {
510 panic!("Gleam e2e generator: unsupported assertion type: {other}");
511 }
512 }
513}
514
515fn json_to_gleam(value: &serde_json::Value) -> String {
517 match value {
518 serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
519 serde_json::Value::Bool(b) => {
520 if *b {
521 "True".to_string()
522 } else {
523 "False".to_string()
524 }
525 }
526 serde_json::Value::Number(n) => n.to_string(),
527 serde_json::Value::Null => "Nil".to_string(),
528 serde_json::Value::Array(arr) => {
529 let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
530 format!("[{}]", items.join(", "))
531 }
532 serde_json::Value::Object(_) => {
533 let json_str = serde_json::to_string(value).unwrap_or_default();
534 format!("\"{}\"", escape_gleam(&json_str))
535 }
536 }
537}