Skip to main content

alef_e2e/codegen/
elixir.rs

1//! Elixir e2e test generator using ExUnit.
2
3use 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
17/// Elixir e2e code generator.
18pub 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        // Resolve call config with overrides.
33        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        // Convert module path to Elixir PascalCase if it looks like snake_case
40        // (e.g., "html_to_markdown" -> "HtmlToMarkdown").
41        // If the override already contains "." (e.g., "Elixir.HtmlToMarkdown"), use as-is.
42        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        // Elixir facade exports async variants with `_async` suffix when the call is async.
52        // Append the suffix only if not already present.
53        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        // Resolve package config.
70        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        // The dep atom must be a valid snake_case Elixir atom (e.g., :html_to_markdown),
77        // derived from the call module name, not the PascalCase module path.
78        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        // Generate mix.exs.
90        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        // Generate lib/e2e_elixir.ex — required so the mix project compiles.
97        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        // Generate test_helper.exs.
104        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        // Generate test files per category.
111        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    // Use a bare atom for the dep name (e.g., :html_to_markdown), not a quoted atom.
180    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/// Build setup lines (e.g. handle creation) and the argument list for the function call.
304///
305/// Returns `(setup_lines, args_string)`.
306#[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            // Generate a create_{name} call using {:ok, name} = ... pattern.
337            // The NIF now accepts config as an optional JSON string (not a NifStruct/NifMap)
338            // so that partial maps work: serde_json::from_str respects #[serde(default)].
339            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                // Serialize the config map to a JSON string with Jason so that Rust can
348                // deserialize it with serde_json and apply field defaults for missing keys.
349                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                // Optional arg with no fixture value: skip entirely.
364                continue;
365            }
366            None | Some(serde_json::Value::Null) => {
367                // Required arg with no fixture value: pass a language-appropriate default.
368                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                // For json_object args with options_type, build a proper struct.
379                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                        // Add setup line to initialize options from default function.
384                        let options_var = "options";
385                        setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
386
387                        // For each field in the options object, add a struct update line.
388                        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                                    // Use atom for enum values, not string
394                                    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                        // Push the variable name as the argument.
407                        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
419/// Returns true if the field expression is a numeric/integer expression
420/// (e.g., a `length(...)` call) rather than a string.
421fn 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    // Skip assertions on fields that don't exist on the result type.
427    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    // Only wrap in String.trim/0 when the expression is actually a string.
440    // Numeric expressions (e.g., length(...)) must not be wrapped.
441    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                // Apply String.trim only for string comparisons, not numeric ones.
453                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                // Use to_string() to handle atoms (enums) as well as strings
465                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                // length(...) == 0
497                let _ = writeln!(out, "      assert {field_expr} == 0");
498            } else {
499                // Handle nil (None) as empty
500                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            // Already handled — the call would fail if it returned {:error, _}.
572        }
573        "error" => {
574            // Handled at the test level.
575        }
576        other => {
577            let _ = writeln!(out, "      # TODO: unsupported assertion type: {other}");
578        }
579    }
580}
581
582/// Convert a category name to an Elixir module-safe PascalCase name.
583fn elixir_module_name(category: &str) -> String {
584    use heck::ToUpperCamelCase;
585    category.to_upper_camel_case()
586}
587
588/// Convert a `serde_json::Value` to an Elixir literal string.
589fn 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}