1use crate::config::E2eConfig;
4use crate::escape::{escape_elixir, sanitize_filename, sanitize_ident};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, CallbackAction, 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 (mut 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 let final_args = if let Some(visitor_spec) = &fixture.visitor {
282 let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
283 format!("{args_str}, {visitor_var}")
284 } else {
285 args_str
286 };
287
288 if expects_error {
289 let _ = writeln!(
290 out,
291 " assert {{:error, _}} = {module_path}.{function_name}({final_args})"
292 );
293 let _ = writeln!(out, " end");
294 let _ = writeln!(out, " end");
295 return;
296 }
297
298 let _ = writeln!(
299 out,
300 " {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
301 );
302
303 for assertion in &fixture.assertions {
304 render_assertion(out, assertion, result_var, field_resolver);
305 }
306
307 let _ = writeln!(out, " end");
308 let _ = writeln!(out, " end");
309}
310
311#[allow(clippy::too_many_arguments)]
315fn build_args_and_setup(
316 input: &serde_json::Value,
317 args: &[crate::config::ArgMapping],
318 module_path: &str,
319 options_type: Option<&str>,
320 options_default_fn: Option<&str>,
321 enum_fields: &HashMap<String, String>,
322 fixture_id: &str,
323 _handle_struct_type: Option<&str>,
324 _handle_atom_list_fields: &std::collections::HashSet<String>,
325) -> (Vec<String>, String) {
326 if args.is_empty() {
327 return (Vec::new(), json_to_elixir(input));
328 }
329
330 let mut setup_lines: Vec<String> = Vec::new();
331 let mut parts: Vec<String> = Vec::new();
332
333 for arg in args {
334 if arg.arg_type == "mock_url" {
335 setup_lines.push(format!(
336 "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
337 arg.name,
338 ));
339 parts.push(arg.name.clone());
340 continue;
341 }
342
343 if arg.arg_type == "handle" {
344 let constructor_name = format!("create_{}", arg.name.to_snake_case());
348 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
349 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
350 let name = &arg.name;
351 if config_value.is_null()
352 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
353 {
354 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
355 } else {
356 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
359 let escaped = escape_elixir(&json_str);
360 setup_lines.push(format!("{name}_config = \"{escaped}\""));
361 setup_lines.push(format!(
362 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
363 ));
364 }
365 parts.push(arg.name.clone());
366 continue;
367 }
368
369 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
370 let val = input.get(field);
371 match val {
372 None | Some(serde_json::Value::Null) if arg.optional => {
373 continue;
375 }
376 None | Some(serde_json::Value::Null) => {
377 let default_val = match arg.arg_type.as_str() {
379 "string" => "\"\"".to_string(),
380 "int" | "integer" => "0".to_string(),
381 "float" | "number" => "0.0".to_string(),
382 "bool" | "boolean" => "false".to_string(),
383 _ => "nil".to_string(),
384 };
385 parts.push(default_val);
386 }
387 Some(v) => {
388 if arg.arg_type == "json_object" && !v.is_null() {
390 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
391 (options_type, options_default_fn, v.as_object())
392 {
393 let options_var = "options";
395 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
396
397 for (k, vv) in obj.iter() {
399 let snake_key = k.to_snake_case();
400 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
401 if let Some(s) = vv.as_str() {
402 let snake_val = s.to_snake_case();
403 format!(":{snake_val}")
405 } else {
406 json_to_elixir(vv)
407 }
408 } else {
409 json_to_elixir(vv)
410 };
411 setup_lines.push(format!(
412 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
413 ));
414 }
415
416 parts.push(options_var.to_string());
418 continue;
419 }
420 }
421 parts.push(json_to_elixir(v));
422 }
423 }
424 }
425
426 (setup_lines, parts.join(", "))
427}
428
429fn is_numeric_expr(field_expr: &str) -> bool {
432 field_expr.starts_with("length(")
433}
434
435fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
436 if let Some(f) = &assertion.field {
438 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
439 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
440 return;
441 }
442 }
443
444 let field_expr = match &assertion.field {
445 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
446 _ => result_var.to_string(),
447 };
448
449 let is_numeric = is_numeric_expr(&field_expr);
452 let trimmed_field_expr = if is_numeric {
453 field_expr.clone()
454 } else {
455 format!("String.trim({field_expr})")
456 };
457
458 match assertion.assertion_type.as_str() {
459 "equals" => {
460 if let Some(expected) = &assertion.value {
461 let elixir_val = json_to_elixir(expected);
462 let is_string_expected = expected.is_string();
464 if is_string_expected && !is_numeric {
465 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
466 } else {
467 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
468 }
469 }
470 }
471 "contains" => {
472 if let Some(expected) = &assertion.value {
473 let elixir_val = json_to_elixir(expected);
474 let _ = writeln!(
476 out,
477 " assert String.contains?(to_string({field_expr}), {elixir_val})"
478 );
479 }
480 }
481 "contains_all" => {
482 if let Some(values) = &assertion.values {
483 for val in values {
484 let elixir_val = json_to_elixir(val);
485 let _ = writeln!(
486 out,
487 " assert String.contains?(to_string({field_expr}), {elixir_val})"
488 );
489 }
490 }
491 }
492 "not_contains" => {
493 if let Some(expected) = &assertion.value {
494 let elixir_val = json_to_elixir(expected);
495 let _ = writeln!(
496 out,
497 " refute String.contains?(to_string({field_expr}), {elixir_val})"
498 );
499 }
500 }
501 "not_empty" => {
502 let _ = writeln!(out, " assert {field_expr} != \"\"");
503 }
504 "is_empty" => {
505 if is_numeric {
506 let _ = writeln!(out, " assert {field_expr} == 0");
508 } else {
509 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
511 }
512 }
513 "contains_any" => {
514 if let Some(values) = &assertion.values {
515 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
516 let list_str = items.join(", ");
517 let _ = writeln!(
518 out,
519 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
520 );
521 }
522 }
523 "greater_than" => {
524 if let Some(val) = &assertion.value {
525 let elixir_val = json_to_elixir(val);
526 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
527 }
528 }
529 "less_than" => {
530 if let Some(val) = &assertion.value {
531 let elixir_val = json_to_elixir(val);
532 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
533 }
534 }
535 "greater_than_or_equal" => {
536 if let Some(val) = &assertion.value {
537 let elixir_val = json_to_elixir(val);
538 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
539 }
540 }
541 "less_than_or_equal" => {
542 if let Some(val) = &assertion.value {
543 let elixir_val = json_to_elixir(val);
544 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
545 }
546 }
547 "starts_with" => {
548 if let Some(expected) = &assertion.value {
549 let elixir_val = json_to_elixir(expected);
550 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
551 }
552 }
553 "ends_with" => {
554 if let Some(expected) = &assertion.value {
555 let elixir_val = json_to_elixir(expected);
556 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
557 }
558 }
559 "min_length" => {
560 if let Some(val) = &assertion.value {
561 if let Some(n) = val.as_u64() {
562 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
563 }
564 }
565 }
566 "max_length" => {
567 if let Some(val) = &assertion.value {
568 if let Some(n) = val.as_u64() {
569 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
570 }
571 }
572 }
573 "count_min" => {
574 if let Some(val) = &assertion.value {
575 if let Some(n) = val.as_u64() {
576 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
577 }
578 }
579 }
580 "count_equals" => {
581 if let Some(val) = &assertion.value {
582 if let Some(n) = val.as_u64() {
583 let _ = writeln!(out, " assert length({field_expr}) == {n}");
584 }
585 }
586 }
587 "is_true" => {
588 let _ = writeln!(out, " assert {field_expr} == true");
589 }
590 "not_error" => {
591 }
593 "error" => {
594 }
596 other => {
597 let _ = writeln!(out, " # TODO: unsupported assertion type: {other}");
598 }
599 }
600}
601
602fn elixir_module_name(category: &str) -> String {
604 use heck::ToUpperCamelCase;
605 category.to_upper_camel_case()
606}
607
608fn json_to_elixir(value: &serde_json::Value) -> String {
610 match value {
611 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
612 serde_json::Value::Bool(true) => "true".to_string(),
613 serde_json::Value::Bool(false) => "false".to_string(),
614 serde_json::Value::Number(n) => n.to_string(),
615 serde_json::Value::Null => "nil".to_string(),
616 serde_json::Value::Array(arr) => {
617 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
618 format!("[{}]", items.join(", "))
619 }
620 serde_json::Value::Object(map) => {
621 let entries: Vec<String> = map
622 .iter()
623 .map(|(k, v)| format!("\"{}\" => {}", k.to_snake_case(), json_to_elixir(v)))
624 .collect();
625 format!("%{{{}}}", entries.join(", "))
626 }
627 }
628}
629
630fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
632 use std::fmt::Write as FmtWrite;
633 let mut visitor_obj = String::new();
634 let _ = writeln!(visitor_obj, "%{{");
635 for (method_name, action) in &visitor_spec.callbacks {
636 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
637 }
638 let _ = writeln!(visitor_obj, " }}");
639
640 setup_lines.push(format!("visitor = {visitor_obj}"));
641 "visitor".to_string()
642}
643
644fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
646 use std::fmt::Write as FmtWrite;
647
648 let handle_method = format!("handle_{}", &method_name[6..]); let params = match method_name {
651 "visit_link" => "_ctx, _href, _text, _title",
652 "visit_image" => "_ctx, _src, _alt, _title",
653 "visit_heading" => "_ctx, _level, text, _id",
654 "visit_code_block" => "_ctx, _lang, _code",
655 "visit_code_inline"
656 | "visit_strong"
657 | "visit_emphasis"
658 | "visit_strikethrough"
659 | "visit_underline"
660 | "visit_subscript"
661 | "visit_superscript"
662 | "visit_mark"
663 | "visit_button"
664 | "visit_summary"
665 | "visit_figcaption"
666 | "visit_definition_term"
667 | "visit_definition_description" => "_ctx, _text",
668 "visit_text" => "_ctx, _text",
669 "visit_list_item" => "_ctx, _ordered, _marker, _text",
670 "visit_blockquote" => "_ctx, _content, _depth",
671 "visit_table_row" => "_ctx, _cells, _is_header",
672 "visit_custom_element" => "_ctx, _tag_name, _html",
673 "visit_form" => "_ctx, _action_url, _method",
674 "visit_input" => "_ctx, _input_type, _name, _value",
675 "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
676 "visit_details" => "_ctx, _is_open",
677 _ => "_ctx",
678 };
679
680 let _ = writeln!(out, " :{handle_method} => fn({params}) ->");
681 match action {
682 CallbackAction::Skip => {
683 let _ = writeln!(out, " :skip");
684 }
685 CallbackAction::Continue => {
686 let _ = writeln!(out, " :continue");
687 }
688 CallbackAction::PreserveHtml => {
689 let _ = writeln!(out, " :preserve_html");
690 }
691 CallbackAction::Custom { output } => {
692 let escaped = escape_elixir(output);
693 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
694 }
695 CallbackAction::CustomTemplate { template } => {
696 let escaped = escape_elixir(template);
698 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
699 }
700 }
701 let _ = writeln!(out, " end,");
702}