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