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.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.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        // The dep atom must be a valid snake_case Elixir atom (e.g., :html_to_markdown),
76        // derived from the call module name, not the PascalCase module path.
77        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        // Generate mix.exs.
83        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        // Generate lib/e2e_elixir.ex — required so the mix project compiles.
90        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        // Generate test_helper.exs.
97        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        // Generate test files per category.
104        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    // Use a bare atom for the dep name (e.g., :html_to_markdown), not a quoted atom.
168    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/// Build setup lines (e.g. handle creation) and the argument list for the function call.
285///
286/// Returns `(setup_lines, args_string)`.
287#[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            // Generate a create_{name} call using {:ok, name} = ... pattern.
318            // The NIF now accepts config as an optional JSON string (not a NifStruct/NifMap)
319            // so that partial maps work: serde_json::from_str respects #[serde(default)].
320            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                // Serialize the config map to a JSON string with Jason so that Rust can
329                // deserialize it with serde_json and apply field defaults for missing keys.
330                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                // Optional arg with no fixture value: skip entirely.
345                continue;
346            }
347            None | Some(serde_json::Value::Null) => {
348                // Required arg with no fixture value: pass a language-appropriate default.
349                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                // For json_object args with options_type, build a proper struct.
360                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                        // Add setup line to initialize options from default function.
365                        let options_var = "options";
366                        setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
367
368                        // For each field in the options object, add a struct update line.
369                        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                                    // Use atom for enum values, not string
375                                    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                        // Push the variable name as the argument.
388                        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
400/// Returns true if the field expression is a numeric/integer expression
401/// (e.g., a `length(...)` call) rather than a string.
402fn 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    // Skip assertions on fields that don't exist on the result type.
408    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    // Only wrap in String.trim/0 when the expression is actually a string.
421    // Numeric expressions (e.g., length(...)) must not be wrapped.
422    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                // Apply String.trim only for string comparisons, not numeric ones.
434                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                // Use to_string() to handle atoms (enums) as well as strings
446                let _ = writeln!(
447                    out,
448                    "      assert String.contains?(to_string({field_expr}), {elixir_val})"
449                );
450            }
451        }
452        "contains_all" => {
453            if let Some(values) = &assertion.values {
454                for val in values {
455                    let elixir_val = json_to_elixir(val);
456                    let _ = writeln!(
457                        out,
458                        "      assert String.contains?(to_string({field_expr}), {elixir_val})"
459                    );
460                }
461            }
462        }
463        "not_contains" => {
464            if let Some(expected) = &assertion.value {
465                let elixir_val = json_to_elixir(expected);
466                let _ = writeln!(
467                    out,
468                    "      refute String.contains?(to_string({field_expr}), {elixir_val})"
469                );
470            }
471        }
472        "not_empty" => {
473            let _ = writeln!(out, "      assert {field_expr} != \"\"");
474        }
475        "is_empty" => {
476            if is_numeric {
477                // length(...) == 0
478                let _ = writeln!(out, "      assert {field_expr} == 0");
479            } else {
480                // Handle nil (None) as empty
481                let _ = writeln!(out, "      assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
482            }
483        }
484        "contains_any" => {
485            if let Some(values) = &assertion.values {
486                let items: Vec<String> = values.iter().map(json_to_elixir).collect();
487                let list_str = items.join(", ");
488                let _ = writeln!(
489                    out,
490                    "      assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
491                );
492            }
493        }
494        "greater_than" => {
495            if let Some(val) = &assertion.value {
496                let elixir_val = json_to_elixir(val);
497                let _ = writeln!(out, "      assert {field_expr} > {elixir_val}");
498            }
499        }
500        "less_than" => {
501            if let Some(val) = &assertion.value {
502                let elixir_val = json_to_elixir(val);
503                let _ = writeln!(out, "      assert {field_expr} < {elixir_val}");
504            }
505        }
506        "greater_than_or_equal" => {
507            if let Some(val) = &assertion.value {
508                let elixir_val = json_to_elixir(val);
509                let _ = writeln!(out, "      assert {field_expr} >= {elixir_val}");
510            }
511        }
512        "less_than_or_equal" => {
513            if let Some(val) = &assertion.value {
514                let elixir_val = json_to_elixir(val);
515                let _ = writeln!(out, "      assert {field_expr} <= {elixir_val}");
516            }
517        }
518        "starts_with" => {
519            if let Some(expected) = &assertion.value {
520                let elixir_val = json_to_elixir(expected);
521                let _ = writeln!(out, "      assert String.starts_with?({field_expr}, {elixir_val})");
522            }
523        }
524        "ends_with" => {
525            if let Some(expected) = &assertion.value {
526                let elixir_val = json_to_elixir(expected);
527                let _ = writeln!(out, "      assert String.ends_with?({field_expr}, {elixir_val})");
528            }
529        }
530        "min_length" => {
531            if let Some(val) = &assertion.value {
532                if let Some(n) = val.as_u64() {
533                    let _ = writeln!(out, "      assert String.length({field_expr}) >= {n}");
534                }
535            }
536        }
537        "max_length" => {
538            if let Some(val) = &assertion.value {
539                if let Some(n) = val.as_u64() {
540                    let _ = writeln!(out, "      assert String.length({field_expr}) <= {n}");
541                }
542            }
543        }
544        "count_min" => {
545            if let Some(val) = &assertion.value {
546                if let Some(n) = val.as_u64() {
547                    let _ = writeln!(out, "      assert length({field_expr}) >= {n}");
548                }
549            }
550        }
551        "not_error" => {
552            // Already handled — the call would fail if it returned {:error, _}.
553        }
554        "error" => {
555            // Handled at the test level.
556        }
557        other => {
558            let _ = writeln!(out, "      # TODO: unsupported assertion type: {other}");
559        }
560    }
561}
562
563/// Convert a category name to an Elixir module-safe PascalCase name.
564fn elixir_module_name(category: &str) -> String {
565    use heck::ToUpperCamelCase;
566    category.to_upper_camel_case()
567}
568
569/// Convert a `serde_json::Value` to an Elixir literal string.
570fn json_to_elixir(value: &serde_json::Value) -> String {
571    match value {
572        serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
573        serde_json::Value::Bool(true) => "true".to_string(),
574        serde_json::Value::Bool(false) => "false".to_string(),
575        serde_json::Value::Number(n) => n.to_string(),
576        serde_json::Value::Null => "nil".to_string(),
577        serde_json::Value::Array(arr) => {
578            let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
579            format!("[{}]", items.join(", "))
580        }
581        serde_json::Value::Object(map) => {
582            let entries: Vec<String> = map
583                .iter()
584                .map(|(k, v)| format!("\"{}\" => {}", k.to_snake_case(), json_to_elixir(v)))
585                .collect();
586            format!("%{{{}}}", entries.join(", "))
587        }
588    }
589}