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 raw_name = sanitize_ident(&fixture.id);
245 let test_name = raw_name.trim_start_matches('_');
246 let test_name = if test_name.is_empty() {
247 raw_name.as_str()
248 } else {
249 test_name
250 };
251 let description = &fixture.description;
252 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
253
254 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
255
256 let _ = writeln!(out, "// {description}");
259 let _ = writeln!(out, "pub fn {test_name}_test() {{");
260
261 for line in &setup_lines {
262 let _ = writeln!(out, " {line}");
263 }
264
265 if expects_error {
266 let _ = writeln!(out, " {module_path}.{function_name}({args_str}) |> should.be_error()");
267 let _ = writeln!(out, "}}");
268 return;
269 }
270
271 let _ = writeln!(out, " let {result_var} = {module_path}.{function_name}({args_str})");
272 let _ = writeln!(out, " {result_var} |> should.be_ok()");
273
274 for assertion in &fixture.assertions {
275 render_assertion(out, assertion, result_var, field_resolver, enum_fields);
276 }
277
278 let _ = writeln!(out, "}}");
279}
280
281fn build_args_and_setup(
283 input: &serde_json::Value,
284 args: &[crate::config::ArgMapping],
285 fixture_id: &str,
286) -> (Vec<String>, String) {
287 if args.is_empty() {
288 return (Vec::new(), String::new());
289 }
290
291 let mut setup_lines: Vec<String> = Vec::new();
292 let mut parts: Vec<String> = Vec::new();
293
294 for arg in args {
295 if arg.arg_type == "mock_url" {
296 setup_lines.push(format!(
297 "let {} = (import \"os\" as os).get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
298 arg.name,
299 ));
300 parts.push(arg.name.clone());
301 continue;
302 }
303
304 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
305 let val = input.get(field);
306 match val {
307 None | Some(serde_json::Value::Null) if arg.optional => {
308 continue;
309 }
310 None | Some(serde_json::Value::Null) => {
311 let default_val = match arg.arg_type.as_str() {
312 "string" => "\"\"".to_string(),
313 "int" | "integer" => "0".to_string(),
314 "float" | "number" => "0.0".to_string(),
315 "bool" | "boolean" => "False".to_string(),
316 _ => "Nil".to_string(),
317 };
318 parts.push(default_val);
319 }
320 Some(v) => {
321 parts.push(json_to_gleam(v));
322 }
323 }
324 }
325
326 (setup_lines, parts.join(", "))
327}
328
329fn render_assertion(
330 out: &mut String,
331 assertion: &Assertion,
332 result_var: &str,
333 field_resolver: &FieldResolver,
334 enum_fields: &HashSet<String>,
335) {
336 if let Some(f) = &assertion.field {
338 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
339 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
340 return;
341 }
342 }
343
344 let _field_is_enum = assertion
346 .field
347 .as_deref()
348 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
349
350 let field_expr = match &assertion.field {
351 Some(f) if !f.is_empty() => field_resolver.accessor(f, "gleam", result_var),
352 _ => result_var.to_string(),
353 };
354
355 match assertion.assertion_type.as_str() {
356 "equals" => {
357 if let Some(expected) = &assertion.value {
358 let gleam_val = json_to_gleam(expected);
359 let _ = writeln!(out, " {field_expr} |> should.equal({gleam_val})");
360 }
361 }
362 "contains" => {
363 if let Some(expected) = &assertion.value {
364 let gleam_val = json_to_gleam(expected);
365 let _ = writeln!(
366 out,
367 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
368 );
369 }
370 }
371 "contains_all" => {
372 if let Some(values) = &assertion.values {
373 for val in values {
374 let gleam_val = json_to_gleam(val);
375 let _ = writeln!(
376 out,
377 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
378 );
379 }
380 }
381 }
382 "not_contains" => {
383 if let Some(expected) = &assertion.value {
384 let gleam_val = json_to_gleam(expected);
385 let _ = writeln!(
386 out,
387 " {field_expr} |> string.contains({gleam_val}) |> should.equal(False)"
388 );
389 }
390 }
391 "not_empty" => {
392 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(False)");
393 }
394 "is_empty" => {
395 let _ = writeln!(out, " {field_expr} |> list.is_empty |> should.equal(True)");
396 }
397 "starts_with" => {
398 if let Some(expected) = &assertion.value {
399 let gleam_val = json_to_gleam(expected);
400 let _ = writeln!(
401 out,
402 " {field_expr} |> string.starts_with({gleam_val}) |> should.equal(True)"
403 );
404 }
405 }
406 "ends_with" => {
407 if let Some(expected) = &assertion.value {
408 let gleam_val = json_to_gleam(expected);
409 let _ = writeln!(
410 out,
411 " {field_expr} |> string.ends_with({gleam_val}) |> should.equal(True)"
412 );
413 }
414 }
415 "min_length" => {
416 if let Some(val) = &assertion.value {
417 if let Some(n) = val.as_u64() {
418 let _ = writeln!(
419 out,
420 " {field_expr} |> string.length |> int.is_at_least({n}) |> should.equal(True)"
421 );
422 }
423 }
424 }
425 "max_length" => {
426 if let Some(val) = &assertion.value {
427 if let Some(n) = val.as_u64() {
428 let _ = writeln!(
429 out,
430 " {field_expr} |> string.length |> int.is_at_most({n}) |> should.equal(True)"
431 );
432 }
433 }
434 }
435 "count_min" => {
436 if let Some(val) = &assertion.value {
437 if let Some(n) = val.as_u64() {
438 let _ = writeln!(
439 out,
440 " {field_expr} |> list.length |> int.is_at_least({n}) |> should.equal(True)"
441 );
442 }
443 }
444 }
445 "count_equals" => {
446 if let Some(val) = &assertion.value {
447 if let Some(n) = val.as_u64() {
448 let _ = writeln!(out, " {field_expr} |> list.length |> should.equal({n})");
449 }
450 }
451 }
452 "is_true" => {
453 let _ = writeln!(out, " {field_expr} |> should.equal(True)");
454 }
455 "is_false" => {
456 let _ = writeln!(out, " {field_expr} |> should.equal(False)");
457 }
458 "not_error" => {
459 }
461 "error" => {
462 }
464 "greater_than" => {
465 if let Some(val) = &assertion.value {
466 let gleam_val = json_to_gleam(val);
467 let _ = writeln!(
468 out,
469 " {field_expr} |> int.is_strictly_greater_than({gleam_val}) |> should.equal(True)"
470 );
471 }
472 }
473 "less_than" => {
474 if let Some(val) = &assertion.value {
475 let gleam_val = json_to_gleam(val);
476 let _ = writeln!(
477 out,
478 " {field_expr} |> int.is_strictly_less_than({gleam_val}) |> should.equal(True)"
479 );
480 }
481 }
482 "greater_than_or_equal" => {
483 if let Some(val) = &assertion.value {
484 let gleam_val = json_to_gleam(val);
485 let _ = writeln!(
486 out,
487 " {field_expr} |> int.is_at_least({gleam_val}) |> should.equal(True)"
488 );
489 }
490 }
491 "less_than_or_equal" => {
492 if let Some(val) = &assertion.value {
493 let gleam_val = json_to_gleam(val);
494 let _ = writeln!(
495 out,
496 " {field_expr} |> int.is_at_most({gleam_val}) |> should.equal(True)"
497 );
498 }
499 }
500 "contains_any" => {
501 if let Some(values) = &assertion.values {
502 for val in values {
503 let gleam_val = json_to_gleam(val);
504 let _ = writeln!(
505 out,
506 " {field_expr} |> string.contains({gleam_val}) |> should.equal(True)"
507 );
508 }
509 }
510 }
511 "matches_regex" => {
512 let _ = writeln!(out, " // regex match not yet implemented for Gleam");
513 }
514 "method_result" => {
515 let _ = writeln!(out, " // method_result assertions not yet implemented for Gleam");
516 }
517 other => {
518 panic!("Gleam e2e generator: unsupported assertion type: {other}");
519 }
520 }
521}
522
523fn json_to_gleam(value: &serde_json::Value) -> String {
525 match value {
526 serde_json::Value::String(s) => format!("\"{}\"", escape_gleam(s)),
527 serde_json::Value::Bool(b) => {
528 if *b {
529 "True".to_string()
530 } else {
531 "False".to_string()
532 }
533 }
534 serde_json::Value::Number(n) => n.to_string(),
535 serde_json::Value::Null => "Nil".to_string(),
536 serde_json::Value::Array(arr) => {
537 let items: Vec<String> = arr.iter().map(json_to_gleam).collect();
538 format!("[{}]", items.join(", "))
539 }
540 serde_json::Value::Object(_) => {
541 let json_str = serde_json::to_string(value).unwrap_or_default();
542 format!("\"{}\"", escape_gleam(&json_str))
543 }
544 }
545}