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 std::fmt::Write as FmtWrite;
11use std::path::PathBuf;
12
13use super::E2eCodegen;
14
15/// Elixir e2e code generator.
16pub struct ElixirCodegen;
17
18impl E2eCodegen for ElixirCodegen {
19    fn generate(
20        &self,
21        groups: &[FixtureGroup],
22        e2e_config: &E2eConfig,
23        _alef_config: &AlefConfig,
24    ) -> Result<Vec<GeneratedFile>> {
25        let lang = self.language_name();
26        let output_base = PathBuf::from(&e2e_config.output).join(lang);
27
28        let mut files = Vec::new();
29
30        // Resolve call config with overrides.
31        let call = &e2e_config.call;
32        let overrides = call.overrides.get(lang);
33        let module_path = overrides
34            .and_then(|o| o.module.as_ref())
35            .cloned()
36            .unwrap_or_else(|| call.module.clone());
37        let function_name = overrides
38            .and_then(|o| o.function.as_ref())
39            .cloned()
40            .unwrap_or_else(|| call.function.clone());
41        let result_var = &call.result_var;
42
43        // Resolve package config.
44        let elixir_pkg = e2e_config.packages.get("elixir");
45        let pkg_path = elixir_pkg
46            .and_then(|p| p.path.as_ref())
47            .cloned()
48            .unwrap_or_else(|| "../../packages/elixir".to_string());
49        let pkg_name = elixir_pkg
50            .and_then(|p| p.name.as_ref())
51            .cloned()
52            .unwrap_or_else(|| module_path.clone());
53
54        // Generate mix.exs.
55        files.push(GeneratedFile {
56            path: output_base.join("mix.exs"),
57            content: render_mix_exs(&pkg_name, &pkg_path),
58            generated_header: false,
59        });
60
61        // Generate test_helper.exs.
62        files.push(GeneratedFile {
63            path: output_base.join("test").join("test_helper.exs"),
64            content: "ExUnit.start()\n".to_string(),
65            generated_header: false,
66        });
67
68        // Generate test files per category.
69        for group in groups {
70            let active: Vec<&Fixture> = group
71                .fixtures
72                .iter()
73                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
74                .collect();
75
76            if active.is_empty() {
77                continue;
78            }
79
80            let filename = format!("{}_test.exs", sanitize_filename(&group.category));
81            let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
82            let content = render_test_file(
83                &group.category,
84                &active,
85                &module_path,
86                &function_name,
87                result_var,
88                &e2e_config.call.args,
89                &field_resolver,
90            );
91            files.push(GeneratedFile {
92                path: output_base.join("test").join(filename),
93                content,
94                generated_header: true,
95            });
96        }
97
98        Ok(files)
99    }
100
101    fn language_name(&self) -> &'static str {
102        "elixir"
103    }
104}
105
106fn render_mix_exs(pkg_name: &str, pkg_path: &str) -> String {
107    let mut out = String::new();
108    let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
109    let _ = writeln!(out, "  use Mix.Project");
110    let _ = writeln!(out);
111    let _ = writeln!(out, "  def project do");
112    let _ = writeln!(out, "    [");
113    let _ = writeln!(out, "      app: :e2e_elixir,");
114    let _ = writeln!(out, "      version: \"0.1.0\",");
115    let _ = writeln!(out, "      elixir: \"~> 1.14\",");
116    let _ = writeln!(out, "      deps: deps()");
117    let _ = writeln!(out, "    ]");
118    let _ = writeln!(out, "  end");
119    let _ = writeln!(out);
120    let _ = writeln!(out, "  defp deps do");
121    let _ = writeln!(out, "    [");
122    let dep_line = format!("      {{:\"{pkg_name}\", path: \"{pkg_path}\"}}");
123    let _ = writeln!(out, "{dep_line}");
124    let _ = writeln!(out, "    ]");
125    let _ = writeln!(out, "  end");
126    let _ = writeln!(out, "end");
127    out
128}
129
130fn render_test_file(
131    category: &str,
132    fixtures: &[&Fixture],
133    module_path: &str,
134    function_name: &str,
135    result_var: &str,
136    args: &[crate::config::ArgMapping],
137    field_resolver: &FieldResolver,
138) -> String {
139    let mut out = String::new();
140    let _ = writeln!(out, "# E2e tests for category: {category}");
141    let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
142    let _ = writeln!(out, "  use ExUnit.Case, async: true");
143    let _ = writeln!(out);
144
145    for (i, fixture) in fixtures.iter().enumerate() {
146        render_test_case(
147            &mut out,
148            fixture,
149            module_path,
150            function_name,
151            result_var,
152            args,
153            field_resolver,
154        );
155        if i + 1 < fixtures.len() {
156            let _ = writeln!(out);
157        }
158    }
159
160    let _ = writeln!(out, "end");
161    out
162}
163
164fn render_test_case(
165    out: &mut String,
166    fixture: &Fixture,
167    module_path: &str,
168    function_name: &str,
169    result_var: &str,
170    args: &[crate::config::ArgMapping],
171    field_resolver: &FieldResolver,
172) {
173    let test_name = sanitize_ident(&fixture.id);
174    let description = fixture.description.replace('"', "\\\"");
175
176    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
177
178    let args_str = build_args_string(&fixture.input, args);
179
180    if expects_error {
181        let _ = writeln!(out, "  describe \"{test_name}\" do");
182        let _ = writeln!(out, "    test \"{description}\" do");
183        let _ = writeln!(out, "      assert_raise RuntimeError, fn ->");
184        let _ = writeln!(out, "        {module_path}.{function_name}({args_str})");
185        let _ = writeln!(out, "      end");
186        let _ = writeln!(out, "    end");
187        let _ = writeln!(out, "  end");
188        return;
189    }
190
191    let _ = writeln!(out, "  describe \"{test_name}\" do");
192    let _ = writeln!(out, "    test \"{description}\" do");
193    let _ = writeln!(out, "      {result_var} = {module_path}.{function_name}({args_str})");
194
195    for assertion in &fixture.assertions {
196        render_assertion(out, assertion, result_var, field_resolver);
197    }
198
199    let _ = writeln!(out, "    end");
200    let _ = writeln!(out, "  end");
201}
202
203fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
204    if args.is_empty() {
205        return json_to_elixir(input);
206    }
207
208    let parts: Vec<String> = args
209        .iter()
210        .filter_map(|arg| {
211            let val = input.get(&arg.field)?;
212            if val.is_null() && arg.optional {
213                return None;
214            }
215            Some(json_to_elixir(val))
216        })
217        .collect();
218
219    parts.join(", ")
220}
221
222fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
223    let field_expr = match &assertion.field {
224        Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
225        _ => result_var.to_string(),
226    };
227
228    match assertion.assertion_type.as_str() {
229        "equals" => {
230            if let Some(expected) = &assertion.value {
231                let elixir_val = json_to_elixir(expected);
232                let _ = writeln!(out, "      assert String.trim({field_expr}) == {elixir_val}");
233            }
234        }
235        "contains" => {
236            if let Some(expected) = &assertion.value {
237                let elixir_val = json_to_elixir(expected);
238                let _ = writeln!(out, "      assert String.contains?({field_expr}, {elixir_val})");
239            }
240        }
241        "contains_all" => {
242            if let Some(values) = &assertion.values {
243                for val in values {
244                    let elixir_val = json_to_elixir(val);
245                    let _ = writeln!(out, "      assert String.contains?({field_expr}, {elixir_val})");
246                }
247            }
248        }
249        "not_contains" => {
250            if let Some(expected) = &assertion.value {
251                let elixir_val = json_to_elixir(expected);
252                let _ = writeln!(out, "      refute String.contains?({field_expr}, {elixir_val})");
253            }
254        }
255        "not_empty" => {
256            let _ = writeln!(out, "      assert {field_expr} != \"\"");
257        }
258        "is_empty" => {
259            let _ = writeln!(out, "      assert {field_expr} == \"\"");
260        }
261        "starts_with" => {
262            if let Some(expected) = &assertion.value {
263                let elixir_val = json_to_elixir(expected);
264                let _ = writeln!(out, "      assert String.starts_with?({field_expr}, {elixir_val})");
265            }
266        }
267        "ends_with" => {
268            if let Some(expected) = &assertion.value {
269                let elixir_val = json_to_elixir(expected);
270                let _ = writeln!(out, "      assert String.ends_with?({field_expr}, {elixir_val})");
271            }
272        }
273        "min_length" => {
274            if let Some(val) = &assertion.value {
275                if let Some(n) = val.as_u64() {
276                    let _ = writeln!(out, "      assert String.length({field_expr}) >= {n}");
277                }
278            }
279        }
280        "max_length" => {
281            if let Some(val) = &assertion.value {
282                if let Some(n) = val.as_u64() {
283                    let _ = writeln!(out, "      assert String.length({field_expr}) <= {n}");
284                }
285            }
286        }
287        "not_error" => {
288            // Already handled — the call would raise if it failed.
289        }
290        "error" => {
291            // Handled at the test level.
292        }
293        other => {
294            let _ = writeln!(out, "      # TODO: unsupported assertion type: {other}");
295        }
296    }
297}
298
299/// Convert a category name to an Elixir module-safe PascalCase name.
300fn elixir_module_name(category: &str) -> String {
301    use heck::ToUpperCamelCase;
302    category.to_upper_camel_case()
303}
304
305/// Convert a `serde_json::Value` to an Elixir literal string.
306fn json_to_elixir(value: &serde_json::Value) -> String {
307    match value {
308        serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
309        serde_json::Value::Bool(true) => "true".to_string(),
310        serde_json::Value::Bool(false) => "false".to_string(),
311        serde_json::Value::Number(n) => n.to_string(),
312        serde_json::Value::Null => "nil".to_string(),
313        serde_json::Value::Array(arr) => {
314            let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
315            format!("[{}]", items.join(", "))
316        }
317        serde_json::Value::Object(map) => {
318            let entries: Vec<String> = map
319                .iter()
320                .map(|(k, v)| format!("{}: {}", k, json_to_elixir(v)))
321                .collect();
322            format!("%{{{}}}", entries.join(", "))
323        }
324    }
325}