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 std::fmt::Write as FmtWrite;
11use std::path::PathBuf;
12
13use super::E2eCodegen;
14
15pub 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 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 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 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 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 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 }
290 "error" => {
291 }
293 other => {
294 let _ = writeln!(out, " # TODO: unsupported assertion type: {other}");
295 }
296 }
297}
298
299fn elixir_module_name(category: &str) -> String {
301 use heck::ToUpperCamelCase;
302 category.to_upper_camel_case()
303}
304
305fn 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}