1use crate::config::E2eConfig;
4use crate::escape::{escape_elixir, sanitize_filename, sanitize_ident};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, Fixture, FixtureGroup};
7use alef_core::backend::GeneratedFile;
8use alef_core::config::AlefConfig;
9use anyhow::Result;
10use heck::ToSnakeCase;
11use std::collections::HashMap;
12use std::fmt::Write as FmtWrite;
13use std::path::PathBuf;
14
15use super::E2eCodegen;
16
17pub struct ElixirCodegen;
19
20impl E2eCodegen for ElixirCodegen {
21 fn generate(
22 &self,
23 groups: &[FixtureGroup],
24 e2e_config: &E2eConfig,
25 _alef_config: &AlefConfig,
26 ) -> Result<Vec<GeneratedFile>> {
27 let lang = self.language_name();
28 let output_base = PathBuf::from(&e2e_config.output).join(lang);
29
30 let mut files = Vec::new();
31
32 let call = &e2e_config.call;
34 let overrides = call.overrides.get(lang);
35 let raw_module = overrides
36 .and_then(|o| o.module.as_ref())
37 .cloned()
38 .unwrap_or_else(|| call.module.clone());
39 let module_path = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase()) {
43 raw_module.clone()
44 } else {
45 elixir_module_name(&raw_module)
46 };
47 let base_function_name = overrides
48 .and_then(|o| o.function.as_ref())
49 .cloned()
50 .unwrap_or_else(|| call.function.clone());
51 let function_name = if call.r#async && !base_function_name.ends_with("_async") {
54 format!("{base_function_name}_async")
55 } else {
56 base_function_name
57 };
58 let options_type = overrides.and_then(|o| o.options_type.clone());
59 let options_default_fn = overrides.and_then(|o| o.options_via.clone());
60 let empty_enum_fields = HashMap::new();
61 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
62 let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
63 let empty_atom_fields = std::collections::HashSet::new();
64 let handle_atom_list_fields = overrides
65 .map(|o| &o.handle_atom_list_fields)
66 .unwrap_or(&empty_atom_fields);
67 let result_var = &call.result_var;
68
69 let elixir_pkg = e2e_config.packages.get("elixir");
71 let pkg_path = elixir_pkg
72 .and_then(|p| p.path.as_ref())
73 .cloned()
74 .unwrap_or_else(|| "../../packages/elixir".to_string());
75 let dep_atom = elixir_pkg
78 .and_then(|p| p.name.as_ref())
79 .cloned()
80 .unwrap_or_else(|| raw_module.to_snake_case());
81
82 files.push(GeneratedFile {
84 path: output_base.join("mix.exs"),
85 content: render_mix_exs(&dep_atom, &pkg_path),
86 generated_header: false,
87 });
88
89 files.push(GeneratedFile {
91 path: output_base.join("lib").join("e2e_elixir.ex"),
92 content: "defmodule E2eElixir do\n @moduledoc false\nend\n".to_string(),
93 generated_header: false,
94 });
95
96 files.push(GeneratedFile {
98 path: output_base.join("test").join("test_helper.exs"),
99 content: "ExUnit.start()\n".to_string(),
100 generated_header: false,
101 });
102
103 for group in groups {
105 let active: Vec<&Fixture> = group
106 .fixtures
107 .iter()
108 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
109 .collect();
110
111 if active.is_empty() {
112 continue;
113 }
114
115 let filename = format!("{}_test.exs", sanitize_filename(&group.category));
116 let field_resolver = FieldResolver::new(
117 &e2e_config.fields,
118 &e2e_config.fields_optional,
119 &e2e_config.result_fields,
120 &e2e_config.fields_array,
121 );
122 let content = render_test_file(
123 &group.category,
124 &active,
125 &module_path,
126 &function_name,
127 result_var,
128 &e2e_config.call.args,
129 &field_resolver,
130 options_type.as_deref(),
131 options_default_fn.as_deref(),
132 enum_fields,
133 handle_struct_type.as_deref(),
134 handle_atom_list_fields,
135 );
136 files.push(GeneratedFile {
137 path: output_base.join("test").join(filename),
138 content,
139 generated_header: true,
140 });
141 }
142
143 Ok(files)
144 }
145
146 fn language_name(&self) -> &'static str {
147 "elixir"
148 }
149}
150
151fn render_mix_exs(dep_atom: &str, pkg_path: &str) -> String {
152 let mut out = String::new();
153 let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
154 let _ = writeln!(out, " use Mix.Project");
155 let _ = writeln!(out);
156 let _ = writeln!(out, " def project do");
157 let _ = writeln!(out, " [");
158 let _ = writeln!(out, " app: :e2e_elixir,");
159 let _ = writeln!(out, " version: \"0.1.0\",");
160 let _ = writeln!(out, " elixir: \"~> 1.14\",");
161 let _ = writeln!(out, " deps: deps()");
162 let _ = writeln!(out, " ]");
163 let _ = writeln!(out, " end");
164 let _ = writeln!(out);
165 let _ = writeln!(out, " defp deps do");
166 let _ = writeln!(out, " [");
167 let dep_line = format!(" {{:{dep_atom}, path: \"{pkg_path}\"}}");
169 let _ = writeln!(out, "{dep_line}");
170 let _ = writeln!(out, " ]");
171 let _ = writeln!(out, " end");
172 let _ = writeln!(out, "end");
173 out
174}
175
176#[allow(clippy::too_many_arguments)]
177fn render_test_file(
178 category: &str,
179 fixtures: &[&Fixture],
180 module_path: &str,
181 function_name: &str,
182 result_var: &str,
183 args: &[crate::config::ArgMapping],
184 field_resolver: &FieldResolver,
185 options_type: Option<&str>,
186 options_default_fn: Option<&str>,
187 enum_fields: &HashMap<String, String>,
188 handle_struct_type: Option<&str>,
189 handle_atom_list_fields: &std::collections::HashSet<String>,
190) -> String {
191 let mut out = String::new();
192 let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
193 let _ = writeln!(out, "# E2e tests for category: {category}");
194 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
195 let _ = writeln!(out, " use ExUnit.Case, async: true");
196 let _ = writeln!(out);
197
198 for (i, fixture) in fixtures.iter().enumerate() {
199 render_test_case(
200 &mut out,
201 fixture,
202 module_path,
203 function_name,
204 result_var,
205 args,
206 field_resolver,
207 options_type,
208 options_default_fn,
209 enum_fields,
210 handle_struct_type,
211 handle_atom_list_fields,
212 );
213 if i + 1 < fixtures.len() {
214 let _ = writeln!(out);
215 }
216 }
217
218 let _ = writeln!(out, "end");
219 out
220}
221
222#[allow(clippy::too_many_arguments)]
223fn render_test_case(
224 out: &mut String,
225 fixture: &Fixture,
226 module_path: &str,
227 function_name: &str,
228 result_var: &str,
229 args: &[crate::config::ArgMapping],
230 field_resolver: &FieldResolver,
231 options_type: Option<&str>,
232 options_default_fn: Option<&str>,
233 enum_fields: &HashMap<String, String>,
234 handle_struct_type: Option<&str>,
235 handle_atom_list_fields: &std::collections::HashSet<String>,
236) {
237 let test_name = sanitize_ident(&fixture.id);
238 let description = fixture.description.replace('"', "\\\"");
239
240 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
241
242 let (setup_lines, args_str) = build_args_and_setup(
243 &fixture.input,
244 args,
245 module_path,
246 options_type,
247 options_default_fn,
248 enum_fields,
249 &fixture.id,
250 handle_struct_type,
251 handle_atom_list_fields,
252 );
253
254 let _ = writeln!(out, " describe \"{test_name}\" do");
255 let _ = writeln!(out, " test \"{description}\" do");
256
257 for line in &setup_lines {
258 let _ = writeln!(out, " {line}");
259 }
260
261 if expects_error {
262 let _ = writeln!(
263 out,
264 " assert {{:error, _}} = {module_path}.{function_name}({args_str})"
265 );
266 let _ = writeln!(out, " end");
267 let _ = writeln!(out, " end");
268 return;
269 }
270
271 let _ = writeln!(
272 out,
273 " {{:ok, {result_var}}} = {module_path}.{function_name}({args_str})"
274 );
275
276 for assertion in &fixture.assertions {
277 render_assertion(out, assertion, result_var, field_resolver);
278 }
279
280 let _ = writeln!(out, " end");
281 let _ = writeln!(out, " end");
282}
283
284#[allow(clippy::too_many_arguments)]
288fn build_args_and_setup(
289 input: &serde_json::Value,
290 args: &[crate::config::ArgMapping],
291 module_path: &str,
292 options_type: Option<&str>,
293 options_default_fn: Option<&str>,
294 enum_fields: &HashMap<String, String>,
295 fixture_id: &str,
296 _handle_struct_type: Option<&str>,
297 _handle_atom_list_fields: &std::collections::HashSet<String>,
298) -> (Vec<String>, String) {
299 if args.is_empty() {
300 return (Vec::new(), json_to_elixir(input));
301 }
302
303 let mut setup_lines: Vec<String> = Vec::new();
304 let mut parts: Vec<String> = Vec::new();
305
306 for arg in args {
307 if arg.arg_type == "mock_url" {
308 setup_lines.push(format!(
309 "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
310 arg.name,
311 ));
312 parts.push(arg.name.clone());
313 continue;
314 }
315
316 if arg.arg_type == "handle" {
317 let constructor_name = format!("create_{}", arg.name.to_snake_case());
321 let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
322 let name = &arg.name;
323 if config_value.is_null()
324 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
325 {
326 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
327 } else {
328 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
331 let escaped = escape_elixir(&json_str);
332 setup_lines.push(format!("{name}_config = \"{escaped}\""));
333 setup_lines.push(format!(
334 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
335 ));
336 }
337 parts.push(arg.name.clone());
338 continue;
339 }
340
341 let val = input.get(&arg.field);
342 match val {
343 None | Some(serde_json::Value::Null) if arg.optional => {
344 continue;
346 }
347 None | Some(serde_json::Value::Null) => {
348 let default_val = match arg.arg_type.as_str() {
350 "string" => "\"\"".to_string(),
351 "int" | "integer" => "0".to_string(),
352 "float" | "number" => "0.0".to_string(),
353 "bool" | "boolean" => "false".to_string(),
354 _ => "nil".to_string(),
355 };
356 parts.push(default_val);
357 }
358 Some(v) => {
359 if arg.arg_type == "json_object" && !v.is_null() {
361 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
362 (options_type, options_default_fn, v.as_object())
363 {
364 let options_var = "options";
366 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
367
368 for (k, vv) in obj.iter() {
370 let snake_key = k.to_snake_case();
371 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
372 if let Some(s) = vv.as_str() {
373 let snake_val = s.to_snake_case();
374 format!(":{snake_val}")
376 } else {
377 json_to_elixir(vv)
378 }
379 } else {
380 json_to_elixir(vv)
381 };
382 setup_lines.push(format!(
383 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
384 ));
385 }
386
387 parts.push(options_var.to_string());
389 continue;
390 }
391 }
392 parts.push(json_to_elixir(v));
393 }
394 }
395 }
396
397 (setup_lines, parts.join(", "))
398}
399
400fn is_numeric_expr(field_expr: &str) -> bool {
403 field_expr.starts_with("length(")
404}
405
406fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
407 if let Some(f) = &assertion.field {
409 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
410 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
411 return;
412 }
413 }
414
415 let field_expr = match &assertion.field {
416 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
417 _ => result_var.to_string(),
418 };
419
420 let is_numeric = is_numeric_expr(&field_expr);
423 let trimmed_field_expr = if is_numeric {
424 field_expr.clone()
425 } else {
426 format!("String.trim({field_expr})")
427 };
428
429 match assertion.assertion_type.as_str() {
430 "equals" => {
431 if let Some(expected) = &assertion.value {
432 let elixir_val = json_to_elixir(expected);
433 let is_string_expected = expected.is_string();
435 if is_string_expected && !is_numeric {
436 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
437 } else {
438 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
439 }
440 }
441 }
442 "contains" => {
443 if let Some(expected) = &assertion.value {
444 let elixir_val = json_to_elixir(expected);
445 let _ = writeln!(out, " assert String.contains?({field_expr}, {elixir_val})");
446 }
447 }
448 "contains_all" => {
449 if let Some(values) = &assertion.values {
450 for val in values {
451 let elixir_val = json_to_elixir(val);
452 let _ = writeln!(out, " assert String.contains?({field_expr}, {elixir_val})");
453 }
454 }
455 }
456 "not_contains" => {
457 if let Some(expected) = &assertion.value {
458 let elixir_val = json_to_elixir(expected);
459 let _ = writeln!(out, " refute String.contains?({field_expr}, {elixir_val})");
460 }
461 }
462 "not_empty" => {
463 let _ = writeln!(out, " assert {field_expr} != \"\"");
464 }
465 "is_empty" => {
466 if is_numeric {
467 let _ = writeln!(out, " assert {field_expr} == 0");
469 } else {
470 let _ = writeln!(out, " assert {trimmed_field_expr} == \"\"");
471 }
472 }
473 "contains_any" => {
474 if let Some(values) = &assertion.values {
475 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
476 let list_str = items.join(", ");
477 let _ = writeln!(
478 out,
479 " assert Enum.any?([{list_str}], fn v -> String.contains?({field_expr}, v) end)"
480 );
481 }
482 }
483 "greater_than" => {
484 if let Some(val) = &assertion.value {
485 let elixir_val = json_to_elixir(val);
486 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
487 }
488 }
489 "less_than" => {
490 if let Some(val) = &assertion.value {
491 let elixir_val = json_to_elixir(val);
492 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
493 }
494 }
495 "greater_than_or_equal" => {
496 if let Some(val) = &assertion.value {
497 let elixir_val = json_to_elixir(val);
498 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
499 }
500 }
501 "less_than_or_equal" => {
502 if let Some(val) = &assertion.value {
503 let elixir_val = json_to_elixir(val);
504 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
505 }
506 }
507 "starts_with" => {
508 if let Some(expected) = &assertion.value {
509 let elixir_val = json_to_elixir(expected);
510 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
511 }
512 }
513 "ends_with" => {
514 if let Some(expected) = &assertion.value {
515 let elixir_val = json_to_elixir(expected);
516 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
517 }
518 }
519 "min_length" => {
520 if let Some(val) = &assertion.value {
521 if let Some(n) = val.as_u64() {
522 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
523 }
524 }
525 }
526 "max_length" => {
527 if let Some(val) = &assertion.value {
528 if let Some(n) = val.as_u64() {
529 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
530 }
531 }
532 }
533 "count_min" => {
534 if let Some(val) = &assertion.value {
535 if let Some(n) = val.as_u64() {
536 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
537 }
538 }
539 }
540 "not_error" => {
541 }
543 "error" => {
544 }
546 other => {
547 let _ = writeln!(out, " # TODO: unsupported assertion type: {other}");
548 }
549 }
550}
551
552fn elixir_module_name(category: &str) -> String {
554 use heck::ToUpperCamelCase;
555 category.to_upper_camel_case()
556}
557
558fn json_to_elixir(value: &serde_json::Value) -> String {
560 match value {
561 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
562 serde_json::Value::Bool(true) => "true".to_string(),
563 serde_json::Value::Bool(false) => "false".to_string(),
564 serde_json::Value::Number(n) => n.to_string(),
565 serde_json::Value::Null => "nil".to_string(),
566 serde_json::Value::Array(arr) => {
567 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
568 format!("[{}]", items.join(", "))
569 }
570 serde_json::Value::Object(map) => {
571 let entries: Vec<String> = map
572 .iter()
573 .map(|(k, v)| format!("\"{}\" => {}", k.to_snake_case(), json_to_elixir(v)))
574 .collect();
575 format!("%{{{}}}", entries.join(", "))
576 }
577 }
578}