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, HttpFixture, ValidationErrorExpectation};
7use alef_core::backend::GeneratedFile;
8use alef_core::config::ResolvedCrateConfig;
9use alef_core::hash::{self, CommentStyle};
10use alef_core::template_versions as tv;
11use anyhow::Result;
12use heck::ToSnakeCase;
13use std::collections::HashMap;
14use std::fmt::Write as FmtWrite;
15use std::path::PathBuf;
16
17use super::E2eCodegen;
18use super::client;
19
20pub struct ElixirCodegen;
22
23impl E2eCodegen for ElixirCodegen {
24 fn generate(
25 &self,
26 groups: &[FixtureGroup],
27 e2e_config: &E2eConfig,
28 config: &ResolvedCrateConfig,
29 ) -> Result<Vec<GeneratedFile>> {
30 let lang = self.language_name();
31 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
32
33 let mut files = Vec::new();
34
35 let call = &e2e_config.call;
37 let overrides = call.overrides.get(lang);
38 let raw_module = overrides
39 .and_then(|o| o.module.as_ref())
40 .cloned()
41 .unwrap_or_else(|| call.module.clone());
42 let module_path = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase()) {
46 raw_module.clone()
47 } else {
48 elixir_module_name(&raw_module)
49 };
50 let base_function_name = overrides
51 .and_then(|o| o.function.as_ref())
52 .cloned()
53 .unwrap_or_else(|| call.function.clone());
54 let function_name = if call.r#async && !base_function_name.ends_with("_async") {
57 format!("{base_function_name}_async")
58 } else {
59 base_function_name
60 };
61 let options_type = overrides.and_then(|o| o.options_type.clone());
62 let options_default_fn = overrides.and_then(|o| o.options_via.clone());
63 let empty_enum_fields = HashMap::new();
64 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
65 let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
66 let empty_atom_fields = std::collections::HashSet::new();
67 let handle_atom_list_fields = overrides
68 .map(|o| &o.handle_atom_list_fields)
69 .unwrap_or(&empty_atom_fields);
70 let result_var = &call.result_var;
71
72 let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
74 let has_nif_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| !f.is_http_test()));
75 let has_mock_server_tests = groups.iter().any(|g| {
77 g.fixtures.iter().any(|f| {
78 if f.needs_mock_server() {
79 return true;
80 }
81 let cc = e2e_config.resolve_call(f.call.as_deref());
82 let elixir_override = cc
83 .overrides
84 .get("elixir")
85 .or_else(|| e2e_config.call.overrides.get("elixir"));
86 elixir_override.and_then(|o| o.client_factory.as_deref()).is_some()
87 })
88 });
89
90 let pkg_ref = e2e_config.resolve_package(lang);
92 let pkg_path = if has_nif_tests {
93 pkg_ref.as_ref().and_then(|p| p.path.as_deref()).unwrap_or("")
94 } else {
95 ""
96 };
97
98 let pkg_atom = config.elixir_app_name();
107 files.push(GeneratedFile {
108 path: output_base.join("mix.exs"),
109 content: render_mix_exs(&pkg_atom, pkg_path, e2e_config.dep_mode, has_http_tests, has_nif_tests),
110 generated_header: false,
111 });
112
113 files.push(GeneratedFile {
115 path: output_base.join("lib").join("e2e_elixir.ex"),
116 content: "defmodule E2eElixir do\n @moduledoc false\nend\n".to_string(),
117 generated_header: false,
118 });
119
120 files.push(GeneratedFile {
122 path: output_base.join("test").join("test_helper.exs"),
123 content: render_test_helper(has_http_tests || has_mock_server_tests),
124 generated_header: false,
125 });
126
127 for group in groups {
129 let active: Vec<&Fixture> = group
130 .fixtures
131 .iter()
132 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
133 .collect();
134
135 if active.is_empty() {
136 continue;
137 }
138
139 let filename = format!("{}_test.exs", sanitize_filename(&group.category));
140 let field_resolver = FieldResolver::new(
141 &e2e_config.fields,
142 &e2e_config.fields_optional,
143 &e2e_config.result_fields,
144 &e2e_config.fields_array,
145 &std::collections::HashSet::new(),
146 );
147 let content = render_test_file(
148 &group.category,
149 &active,
150 e2e_config,
151 &module_path,
152 &function_name,
153 result_var,
154 &e2e_config.call.args,
155 &field_resolver,
156 options_type.as_deref(),
157 options_default_fn.as_deref(),
158 enum_fields,
159 handle_struct_type.as_deref(),
160 handle_atom_list_fields,
161 );
162 files.push(GeneratedFile {
163 path: output_base.join("test").join(filename),
164 content,
165 generated_header: true,
166 });
167 }
168
169 Ok(files)
170 }
171
172 fn language_name(&self) -> &'static str {
173 "elixir"
174 }
175}
176
177fn render_test_helper(has_http_tests: bool) -> String {
178 if has_http_tests {
179 r#"ExUnit.start()
180
181# Spawn mock-server binary and set MOCK_SERVER_URL for all tests.
182mock_server_bin = Path.expand("../../rust/target/release/mock-server", __DIR__)
183fixtures_dir = Path.expand("../../../fixtures", __DIR__)
184
185if File.exists?(mock_server_bin) do
186 port = Port.open({:spawn_executable, mock_server_bin}, [
187 :binary,
188 :line,
189 args: [fixtures_dir]
190 ])
191 receive do
192 {^port, {:data, {:eol, "MOCK_SERVER_URL=" <> url}}} ->
193 System.put_env("MOCK_SERVER_URL", url)
194 after
195 30_000 ->
196 raise "mock-server startup timeout"
197 end
198end
199"#
200 .to_string()
201 } else {
202 "ExUnit.start()\n".to_string()
203 }
204}
205
206fn render_mix_exs(
207 pkg_name: &str,
208 pkg_path: &str,
209 dep_mode: crate::config::DependencyMode,
210 has_http_tests: bool,
211 has_nif_tests: bool,
212) -> String {
213 let mut out = String::new();
214 let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
215 let _ = writeln!(out, " use Mix.Project");
216 let _ = writeln!(out);
217 let _ = writeln!(out, " def project do");
218 let _ = writeln!(out, " [");
219 let _ = writeln!(out, " app: :e2e_elixir,");
220 let _ = writeln!(out, " version: \"0.1.0\",");
221 let _ = writeln!(out, " elixir: \"~> 1.14\",");
222 let _ = writeln!(out, " deps: deps()");
223 let _ = writeln!(out, " ]");
224 let _ = writeln!(out, " end");
225 let _ = writeln!(out);
226 let _ = writeln!(out, " defp deps do");
227 let _ = writeln!(out, " [");
228
229 let mut deps: Vec<String> = Vec::new();
231
232 if has_nif_tests && !pkg_path.is_empty() {
234 let pkg_atom = pkg_name;
235 let nif_dep = match dep_mode {
236 crate::config::DependencyMode::Local => {
237 format!(" {{:{pkg_atom}, path: \"{pkg_path}\"}}")
238 }
239 crate::config::DependencyMode::Registry => {
240 format!(" {{:{pkg_atom}, \"{pkg_path}\"}}")
242 }
243 };
244 deps.push(nif_dep);
245 deps.push(format!(
247 " {{:rustler_precompiled, \"{rp}\"}}",
248 rp = tv::hex::RUSTLER_PRECOMPILED
249 ));
250 deps.push(format!(
255 " {{:rustler, \"{rustler}\", runtime: false}}",
256 rustler = tv::hex::RUSTLER
257 ));
258 }
259
260 if has_http_tests {
262 deps.push(format!(" {{:req, \"{req}\"}}", req = tv::hex::REQ));
263 deps.push(format!(" {{:jason, \"{jason}\"}}", jason = tv::hex::JASON));
264 }
265
266 let _ = writeln!(out, "{}", deps.join(",\n"));
267 let _ = writeln!(out, " ]");
268 let _ = writeln!(out, " end");
269 let _ = writeln!(out, "end");
270 out
271}
272
273#[allow(clippy::too_many_arguments)]
274fn render_test_file(
275 category: &str,
276 fixtures: &[&Fixture],
277 e2e_config: &E2eConfig,
278 module_path: &str,
279 function_name: &str,
280 result_var: &str,
281 args: &[crate::config::ArgMapping],
282 field_resolver: &FieldResolver,
283 options_type: Option<&str>,
284 options_default_fn: Option<&str>,
285 enum_fields: &HashMap<String, String>,
286 handle_struct_type: Option<&str>,
287 handle_atom_list_fields: &std::collections::HashSet<String>,
288) -> String {
289 let mut out = String::new();
290 out.push_str(&hash::header(CommentStyle::Hash));
291 let _ = writeln!(out, "# E2e tests for category: {category}");
292 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
293
294 let has_http = fixtures.iter().any(|f| f.is_http_test());
296
297 let async_flag = if has_http { "true" } else { "false" };
300 let _ = writeln!(out, " use ExUnit.Case, async: {async_flag}");
301
302 if has_http {
303 let _ = writeln!(out);
304 let _ = writeln!(out, " defp mock_server_url do");
305 let _ = writeln!(
306 out,
307 " System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
308 );
309 let _ = writeln!(out, " end");
310 }
311
312 let has_array_contains = fixtures.iter().any(|fixture| {
315 fixture.assertions.iter().any(|a| {
316 matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
317 && a.field
318 .as_deref()
319 .is_some_and(|f| !f.is_empty() && field_resolver.is_array(field_resolver.resolve(f)))
320 })
321 });
322 if has_array_contains {
323 let _ = writeln!(out);
324 let _ = writeln!(out, " defp alef_e2e_item_texts(item) when is_binary(item), do: [item]");
325 let _ = writeln!(out, " defp alef_e2e_item_texts(item) do");
326 let _ = writeln!(out, " [:kind, :name, :signature, :path, :alias, :text, :source]");
327 let _ = writeln!(out, " |> Enum.filter(&Map.has_key?(item, &1))");
328 let _ = writeln!(out, " |> Enum.flat_map(fn attr ->");
329 let _ = writeln!(out, " case Map.get(item, attr) do");
330 let _ = writeln!(out, " nil -> []");
331 let _ = writeln!(
332 out,
333 " atom when is_atom(atom) -> [atom |> to_string() |> String.capitalize()]"
334 );
335 let _ = writeln!(out, " str -> [to_string(str)]");
336 let _ = writeln!(out, " end");
337 let _ = writeln!(out, " end)");
338 let _ = writeln!(out, " end");
339 }
340
341 let _ = writeln!(out);
342
343 for (i, fixture) in fixtures.iter().enumerate() {
344 if let Some(http) = &fixture.http {
345 render_http_test_case(&mut out, fixture, http);
346 } else {
347 render_test_case(
348 &mut out,
349 fixture,
350 e2e_config,
351 module_path,
352 function_name,
353 result_var,
354 args,
355 field_resolver,
356 options_type,
357 options_default_fn,
358 enum_fields,
359 handle_struct_type,
360 handle_atom_list_fields,
361 );
362 }
363 if i + 1 < fixtures.len() {
364 let _ = writeln!(out);
365 }
366 }
367
368 let _ = writeln!(out, "end");
369 out
370}
371
372const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
379
380const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
383
384struct ElixirTestClientRenderer<'a> {
388 fixture_id: &'a str,
391 expected_status: u16,
393}
394
395impl<'a> client::TestClientRenderer for ElixirTestClientRenderer<'a> {
396 fn language_name(&self) -> &'static str {
397 "elixir"
398 }
399
400 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
406 let escaped_description = description.replace('"', "\\\"");
407 let _ = writeln!(out, " describe \"{fn_name}\" do");
408 if skip_reason.is_some() {
409 let _ = writeln!(out, " @tag :skip");
410 }
411 let _ = writeln!(out, " test \"{escaped_description}\" do");
412 }
413
414 fn render_test_close(&self, out: &mut String) {
416 let _ = writeln!(out, " end");
417 let _ = writeln!(out, " end");
418 }
419
420 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
422 let method = ctx.method.to_lowercase();
423 let mut opts: Vec<String> = Vec::new();
424
425 if let Some(body) = ctx.body {
426 let elixir_val = json_to_elixir(body);
427 opts.push(format!("json: {elixir_val}"));
428 }
429
430 if !ctx.headers.is_empty() {
431 let header_pairs: Vec<String> = ctx
432 .headers
433 .iter()
434 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
435 .collect();
436 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
437 }
438
439 if !ctx.cookies.is_empty() {
440 let cookie_str = ctx
441 .cookies
442 .iter()
443 .map(|(k, v)| format!("{k}={v}"))
444 .collect::<Vec<_>>()
445 .join("; ");
446 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
447 }
448
449 if !ctx.query_params.is_empty() {
450 let pairs: Vec<String> = ctx
451 .query_params
452 .iter()
453 .map(|(k, v)| {
454 let val_str = match v {
455 serde_json::Value::String(s) => s.clone(),
456 other => other.to_string(),
457 };
458 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
459 })
460 .collect();
461 opts.push(format!("params: [{}]", pairs.join(", ")));
462 }
463
464 if (300..400).contains(&self.expected_status) {
467 opts.push("redirect: false".to_string());
468 }
469
470 let fixture_id = escape_elixir(self.fixture_id);
471 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{fixture_id}\"");
472
473 if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
474 if opts.is_empty() {
475 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
476 } else {
477 let opts_str = opts.join(", ");
478 let _ = writeln!(
479 out,
480 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
481 );
482 }
483 } else {
484 opts.insert(0, format!("method: :{method}"));
485 opts.insert(1, format!("url: {url_expr}"));
486 let opts_str = opts.join(", ");
487 let _ = writeln!(out, " {{:ok, response}} = Req.request({opts_str})");
488 }
489 }
490
491 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
492 let _ = writeln!(out, " assert {response_var}.status == {status}");
493 }
494
495 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
500 let header_key = name.to_lowercase();
501 if header_key == "connection" {
503 return;
504 }
505 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
506 let get_header_expr = format!(
507 "Enum.find_value({response_var}.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
508 );
509 match expected {
510 "<<present>>" => {
511 let _ = writeln!(out, " assert {get_header_expr} != nil");
512 }
513 "<<absent>>" => {
514 let _ = writeln!(out, " assert {get_header_expr} == nil");
515 }
516 "<<uuid>>" => {
517 let var = sanitize_ident(&header_key);
518 let _ = writeln!(out, " header_val_{var} = {get_header_expr}");
519 let _ = writeln!(
520 out,
521 " assert Regex.match?(~r/^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$/i, to_string(header_val_{var}))"
522 );
523 }
524 literal => {
525 let val_lit = format!("\"{}\"", escape_elixir(literal));
526 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
527 }
528 }
529 }
530
531 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
536 let elixir_val = json_to_elixir(expected);
537 match expected {
538 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
539 let _ = writeln!(
540 out,
541 " body_decoded = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
542 );
543 let _ = writeln!(out, " assert body_decoded == {elixir_val}");
544 }
545 _ => {
546 let _ = writeln!(out, " assert {response_var}.body == {elixir_val}");
547 }
548 }
549 }
550
551 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
553 if let Some(obj) = expected.as_object() {
554 let _ = writeln!(
555 out,
556 " decoded_body = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
557 );
558 for (key, val) in obj {
559 let key_lit = format!("\"{}\"", escape_elixir(key));
560 let elixir_val = json_to_elixir(val);
561 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
562 }
563 }
564 }
565
566 fn render_assert_validation_errors(
569 &self,
570 out: &mut String,
571 response_var: &str,
572 errors: &[ValidationErrorExpectation],
573 ) {
574 for err in errors {
575 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
576 let _ = writeln!(
577 out,
578 " assert String.contains?(Jason.encode!({response_var}.body), {msg_lit})"
579 );
580 }
581 }
582}
583
584fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
590 let method = http.request.method.to_uppercase();
591
592 if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
596 let test_name = sanitize_ident(&fixture.id);
597 let test_label = fixture.id.replace('"', "\\\"");
598 let path = &http.request.path;
599 let _ = writeln!(out, " describe \"{test_name}\" do");
600 let _ = writeln!(out, " @tag :skip");
601 let _ = writeln!(out, " test \"{method} {path} - {test_label}\" do");
602 let _ = writeln!(out, " end");
603 let _ = writeln!(out, " end");
604 return;
605 }
606
607 let renderer = ElixirTestClientRenderer {
608 fixture_id: &fixture.id,
609 expected_status: http.expected_response.status_code,
610 };
611 client::http_call::render_http_test(out, &renderer, fixture);
612}
613
614#[allow(clippy::too_many_arguments)]
619fn render_test_case(
620 out: &mut String,
621 fixture: &Fixture,
622 e2e_config: &E2eConfig,
623 default_module_path: &str,
624 default_function_name: &str,
625 default_result_var: &str,
626 args: &[crate::config::ArgMapping],
627 field_resolver: &FieldResolver,
628 options_type: Option<&str>,
629 options_default_fn: Option<&str>,
630 enum_fields: &HashMap<String, String>,
631 handle_struct_type: Option<&str>,
632 handle_atom_list_fields: &std::collections::HashSet<String>,
633) {
634 let test_name = sanitize_ident(&fixture.id);
635 let test_label = fixture.id.replace('"', "\\\"");
636
637 if fixture.mock_response.is_none() && !fixture_has_elixir_callable(fixture, e2e_config) {
643 let _ = writeln!(out, " describe \"{test_name}\" do");
644 let _ = writeln!(out, " @tag :skip");
645 let _ = writeln!(out, " test \"{test_label}\" do");
646 let _ = writeln!(
647 out,
648 " # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
649 );
650 let _ = writeln!(out, " :ok");
651 let _ = writeln!(out, " end");
652 let _ = writeln!(out, " end");
653 return;
654 }
655
656 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
658 let lang = "elixir";
659 let call_overrides = call_config.overrides.get(lang);
660
661 let base_fn = call_overrides
664 .and_then(|o| o.function.as_ref())
665 .cloned()
666 .unwrap_or_else(|| call_config.function.clone());
667 if base_fn.starts_with("batch_extract_") {
668 let _ = writeln!(
669 out,
670 " describe \"{test_name}\" do",
671 test_name = sanitize_ident(&fixture.id)
672 );
673 let _ = writeln!(out, " @tag :skip");
674 let _ = writeln!(
675 out,
676 " test \"{test_label}\" do",
677 test_label = fixture.id.replace('"', "\\\"")
678 );
679 let _ = writeln!(
680 out,
681 " # batch functions excluded from Elixir binding: unsafe NIF tuple marshalling"
682 );
683 let _ = writeln!(out, " :ok");
684 let _ = writeln!(out, " end");
685 let _ = writeln!(out, " end");
686 return;
687 }
688
689 let (module_path, function_name, result_var) = if fixture.call.is_some() {
692 let raw_module = call_overrides
693 .and_then(|o| o.module.as_ref())
694 .cloned()
695 .unwrap_or_else(|| call_config.module.clone());
696 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
697 {
698 raw_module.clone()
699 } else {
700 elixir_module_name(&raw_module)
701 };
702 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
703 format!("{base_fn}_async")
704 } else {
705 base_fn
706 };
707 (resolved_module, resolved_fn, call_config.result_var.clone())
708 } else {
709 (
710 default_module_path.to_string(),
711 default_function_name.to_string(),
712 default_result_var.to_string(),
713 )
714 };
715
716 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
717
718 let (
720 effective_args,
721 effective_options_type,
722 effective_options_default_fn,
723 effective_enum_fields,
724 effective_handle_struct_type,
725 effective_handle_atom_list_fields,
726 );
727 let empty_enum_fields_local: HashMap<String, String>;
728 let empty_atom_fields_local: std::collections::HashSet<String>;
729 let (
730 resolved_args,
731 resolved_options_type,
732 resolved_options_default_fn,
733 resolved_enum_fields_ref,
734 resolved_handle_struct_type,
735 resolved_handle_atom_list_fields_ref,
736 ) = if fixture.call.is_some() {
737 let co = call_config.overrides.get(lang);
738 effective_args = call_config.args.as_slice();
739 effective_options_type = co.and_then(|o| o.options_type.as_deref());
740 effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
741 empty_enum_fields_local = HashMap::new();
742 effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
743 effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
744 empty_atom_fields_local = std::collections::HashSet::new();
745 effective_handle_atom_list_fields = co
746 .map(|o| &o.handle_atom_list_fields)
747 .unwrap_or(&empty_atom_fields_local);
748 (
749 effective_args,
750 effective_options_type,
751 effective_options_default_fn,
752 effective_enum_fields,
753 effective_handle_struct_type,
754 effective_handle_atom_list_fields,
755 )
756 } else {
757 (
758 args as &[_],
759 options_type,
760 options_default_fn,
761 enum_fields,
762 handle_struct_type,
763 handle_atom_list_fields,
764 )
765 };
766
767 let (mut setup_lines, args_str) = build_args_and_setup(
768 &fixture.input,
769 resolved_args,
770 &module_path,
771 resolved_options_type,
772 resolved_options_default_fn,
773 resolved_enum_fields_ref,
774 &fixture.id,
775 resolved_handle_struct_type,
776 resolved_handle_atom_list_fields_ref,
777 );
778
779 let visitor_var = fixture
781 .visitor
782 .as_ref()
783 .map(|visitor_spec| build_elixir_visitor(&mut setup_lines, visitor_spec));
784
785 let final_args = if let Some(ref visitor_var) = visitor_var {
788 let parts: Vec<&str> = args_str.split(", ").collect();
792 if parts.len() == 2 && parts[1] == "nil" {
793 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
795 } else if parts.len() == 2 {
796 setup_lines.push(format!(
798 "{} = Map.put({}, :visitor, {})",
799 parts[1], parts[1], visitor_var
800 ));
801 args_str
802 } else if parts.len() == 1 {
803 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
805 } else {
806 args_str
807 }
808 } else {
809 args_str
810 };
811
812 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
814 e2e_config
815 .call
816 .overrides
817 .get("elixir")
818 .and_then(|o| o.client_factory.as_deref())
819 });
820
821 let effective_args = if client_factory.is_some() {
823 if final_args.is_empty() {
824 "client".to_string()
825 } else {
826 format!("client, {final_args}")
827 }
828 } else {
829 final_args
830 };
831
832 let _ = writeln!(out, " describe \"{test_name}\" do");
833 let _ = writeln!(out, " test \"{test_label}\" do");
834
835 for line in &setup_lines {
836 let _ = writeln!(out, " {line}");
837 }
838
839 if let Some(factory) = client_factory {
841 let fixture_id = &fixture.id;
842 let _ = writeln!(
843 out,
844 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
845 );
846 }
847
848 let returns_result = call_overrides
850 .and_then(|o| o.returns_result)
851 .unwrap_or(call_config.returns_result || client_factory.is_some());
852
853 if expects_error {
854 if returns_result {
855 let _ = writeln!(
856 out,
857 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
858 );
859 } else {
860 let _ = writeln!(out, " _result = {module_path}.{function_name}({effective_args})");
862 }
863 let _ = writeln!(out, " end");
864 let _ = writeln!(out, " end");
865 return;
866 }
867
868 if returns_result {
869 let _ = writeln!(
870 out,
871 " {{:ok, {result_var}}} = {module_path}.{function_name}({effective_args})"
872 );
873 } else {
874 let _ = writeln!(
876 out,
877 " {result_var} = {module_path}.{function_name}({effective_args})"
878 );
879 }
880
881 for assertion in &fixture.assertions {
882 render_assertion(out, assertion, &result_var, field_resolver, &module_path);
883 }
884
885 let _ = writeln!(out, " end");
886 let _ = writeln!(out, " end");
887}
888
889#[allow(clippy::too_many_arguments)]
893fn emit_elixir_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
895 if let Some(items) = arr.as_array() {
896 let item_strs: Vec<String> = items
897 .iter()
898 .filter_map(|item| {
899 if let Some(obj) = item.as_object() {
900 match elem_type {
901 "BatchBytesItem" => {
902 let content = obj.get("content").and_then(|v| v.as_array());
903 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
904 let content_code = if let Some(arr) = content {
905 let bytes: Vec<String> =
906 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
907 format!("<<{}>>", bytes.join(", "))
908 } else {
909 "<<>>".to_string()
910 };
911 Some(format!(
912 "%BatchBytesItem{{content: {}, mime_type: \"{}\"}}",
913 content_code, mime_type
914 ))
915 }
916 "BatchFileItem" => {
917 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
918 Some(format!("%BatchFileItem{{path: \"{}\"}}", path))
919 }
920 _ => None,
921 }
922 } else {
923 None
924 }
925 })
926 .collect();
927 format!("[{}]", item_strs.join(", "))
928 } else {
929 "[]".to_string()
930 }
931}
932
933#[allow(clippy::too_many_arguments)]
934fn build_args_and_setup(
935 input: &serde_json::Value,
936 args: &[crate::config::ArgMapping],
937 module_path: &str,
938 options_type: Option<&str>,
939 options_default_fn: Option<&str>,
940 enum_fields: &HashMap<String, String>,
941 fixture_id: &str,
942 _handle_struct_type: Option<&str>,
943 _handle_atom_list_fields: &std::collections::HashSet<String>,
944) -> (Vec<String>, String) {
945 if args.is_empty() {
946 let is_empty_input = match input {
950 serde_json::Value::Null => true,
951 serde_json::Value::Object(m) => m.is_empty(),
952 _ => false,
953 };
954 if is_empty_input {
955 return (Vec::new(), String::new());
956 }
957 return (Vec::new(), json_to_elixir(input));
958 }
959
960 let mut setup_lines: Vec<String> = Vec::new();
961 let mut parts: Vec<String> = Vec::new();
962
963 for arg in args {
964 if arg.arg_type == "mock_url" {
965 setup_lines.push(format!(
966 "{} = (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
967 arg.name,
968 ));
969 parts.push(arg.name.clone());
970 continue;
971 }
972
973 if arg.arg_type == "handle" {
974 let constructor_name = format!("create_{}", arg.name.to_snake_case());
978 let config_value = if arg.field == "input" {
979 input
980 } else {
981 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
982 input.get(field).unwrap_or(&serde_json::Value::Null)
983 };
984 let name = &arg.name;
985 if config_value.is_null()
986 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
987 {
988 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
989 } else {
990 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
993 let escaped = escape_elixir(&json_str);
994 setup_lines.push(format!("{name}_config = \"{escaped}\""));
995 setup_lines.push(format!(
996 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
997 ));
998 }
999 parts.push(arg.name.clone());
1000 continue;
1001 }
1002
1003 let val = if arg.field == "input" {
1004 Some(input)
1005 } else {
1006 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1007 input.get(field)
1008 };
1009 match val {
1010 None | Some(serde_json::Value::Null) if arg.optional => {
1011 parts.push("nil".to_string());
1014 continue;
1015 }
1016 None | Some(serde_json::Value::Null) => {
1017 let default_val = match arg.arg_type.as_str() {
1019 "string" => "\"\"".to_string(),
1020 "int" | "integer" => "0".to_string(),
1021 "float" | "number" => "0.0".to_string(),
1022 "bool" | "boolean" => "false".to_string(),
1023 _ => "nil".to_string(),
1024 };
1025 parts.push(default_val);
1026 }
1027 Some(v) => {
1028 if arg.arg_type == "file_path" {
1031 if let Some(path_str) = v.as_str() {
1032 let full_path = format!("../../test_documents/{path_str}");
1033 parts.push(format!("\"{}\"", escape_elixir(&full_path)));
1034 continue;
1035 }
1036 }
1037 if arg.arg_type == "bytes" {
1040 if let Some(raw) = v.as_str() {
1041 let var_name = &arg.name;
1042 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
1043 parts.push(format!("\"{}\"", escape_elixir(raw)));
1045 } else {
1046 let first = raw.chars().next().unwrap_or('\0');
1047 let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
1048 && raw
1049 .find('/')
1050 .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
1051 if is_file_path {
1052 let full_path = format!("../../test_documents/{raw}");
1054 let escaped = escape_elixir(&full_path);
1055 setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
1056 parts.push(var_name.to_string());
1057 } else {
1058 setup_lines.push(format!(
1060 "{var_name} = Base.decode64!(\"{}\", padding: false)",
1061 escape_elixir(raw)
1062 ));
1063 parts.push(var_name.to_string());
1064 }
1065 }
1066 continue;
1067 }
1068 }
1069 if arg.arg_type == "json_object" && !v.is_null() {
1071 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
1072 (options_type, options_default_fn, v.as_object())
1073 {
1074 let options_var = "options";
1076 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
1077
1078 for (k, vv) in obj.iter() {
1080 let snake_key = k.to_snake_case();
1081 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1082 if let Some(s) = vv.as_str() {
1083 let snake_val = s.to_snake_case();
1084 format!(":{snake_val}")
1086 } else {
1087 json_to_elixir(vv)
1088 }
1089 } else {
1090 json_to_elixir(vv)
1091 };
1092 setup_lines.push(format!(
1093 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
1094 ));
1095 }
1096
1097 parts.push(options_var.to_string());
1099 continue;
1100 }
1101 if let Some(elem_type) = &arg.element_type {
1103 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1104 parts.push(emit_elixir_batch_item_array(v, elem_type));
1105 continue;
1106 }
1107 if v.is_array() {
1110 parts.push(json_to_elixir(v));
1111 continue;
1112 }
1113 }
1114 if !v.is_null() {
1118 let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
1119 let escaped = escape_elixir(&json_str);
1120 parts.push(format!("\"{escaped}\""));
1121 continue;
1122 }
1123 }
1124 parts.push(json_to_elixir(v));
1125 }
1126 }
1127 }
1128
1129 (setup_lines, parts.join(", "))
1130}
1131
1132fn is_numeric_expr(field_expr: &str) -> bool {
1135 field_expr.starts_with("length(")
1136}
1137
1138fn render_assertion(
1139 out: &mut String,
1140 assertion: &Assertion,
1141 result_var: &str,
1142 field_resolver: &FieldResolver,
1143 module_path: &str,
1144) {
1145 if let Some(f) = &assertion.field {
1148 match f.as_str() {
1149 "chunks_have_content" => {
1150 let pred =
1151 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
1152 match assertion.assertion_type.as_str() {
1153 "is_true" => {
1154 let _ = writeln!(out, " assert {pred}");
1155 }
1156 "is_false" => {
1157 let _ = writeln!(out, " refute {pred}");
1158 }
1159 _ => {
1160 let _ = writeln!(
1161 out,
1162 " # skipped: unsupported assertion type on synthetic field '{f}'"
1163 );
1164 }
1165 }
1166 return;
1167 }
1168 "chunks_have_embeddings" => {
1169 let pred = format!(
1170 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
1171 );
1172 match assertion.assertion_type.as_str() {
1173 "is_true" => {
1174 let _ = writeln!(out, " assert {pred}");
1175 }
1176 "is_false" => {
1177 let _ = writeln!(out, " refute {pred}");
1178 }
1179 _ => {
1180 let _ = writeln!(
1181 out,
1182 " # skipped: unsupported assertion type on synthetic field '{f}'"
1183 );
1184 }
1185 }
1186 return;
1187 }
1188 "embeddings" => {
1192 match assertion.assertion_type.as_str() {
1193 "count_equals" => {
1194 if let Some(val) = &assertion.value {
1195 let ex_val = json_to_elixir(val);
1196 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
1197 }
1198 }
1199 "count_min" => {
1200 if let Some(val) = &assertion.value {
1201 let ex_val = json_to_elixir(val);
1202 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
1203 }
1204 }
1205 "not_empty" => {
1206 let _ = writeln!(out, " assert {result_var} != []");
1207 }
1208 "is_empty" => {
1209 let _ = writeln!(out, " assert {result_var} == []");
1210 }
1211 _ => {
1212 let _ = writeln!(
1213 out,
1214 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1215 );
1216 }
1217 }
1218 return;
1219 }
1220 "embedding_dimensions" => {
1221 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1222 match assertion.assertion_type.as_str() {
1223 "equals" => {
1224 if let Some(val) = &assertion.value {
1225 let ex_val = json_to_elixir(val);
1226 let _ = writeln!(out, " assert {expr} == {ex_val}");
1227 }
1228 }
1229 "greater_than" => {
1230 if let Some(val) = &assertion.value {
1231 let ex_val = json_to_elixir(val);
1232 let _ = writeln!(out, " assert {expr} > {ex_val}");
1233 }
1234 }
1235 _ => {
1236 let _ = writeln!(
1237 out,
1238 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1239 );
1240 }
1241 }
1242 return;
1243 }
1244 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1245 let pred = match f.as_str() {
1246 "embeddings_valid" => {
1247 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1248 }
1249 "embeddings_finite" => {
1250 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1251 }
1252 "embeddings_non_zero" => {
1253 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1254 }
1255 "embeddings_normalized" => {
1256 format!(
1257 "Enum.all?({result_var}, fn e -> n = Enum.reduce(e, 0.0, fn v, acc -> acc + v * v end); abs(n - 1.0) < 1.0e-3 end)"
1258 )
1259 }
1260 _ => unreachable!(),
1261 };
1262 match assertion.assertion_type.as_str() {
1263 "is_true" => {
1264 let _ = writeln!(out, " assert {pred}");
1265 }
1266 "is_false" => {
1267 let _ = writeln!(out, " refute {pred}");
1268 }
1269 _ => {
1270 let _ = writeln!(
1271 out,
1272 " # skipped: unsupported assertion type on synthetic field '{f}'"
1273 );
1274 }
1275 }
1276 return;
1277 }
1278 "keywords" | "keywords_count" => {
1281 let _ = writeln!(
1282 out,
1283 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1284 );
1285 return;
1286 }
1287 _ => {}
1288 }
1289 }
1290
1291 if let Some(f) = &assertion.field {
1293 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1294 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1295 return;
1296 }
1297 }
1298
1299 let field_expr = match &assertion.field {
1300 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1301 _ => result_var.to_string(),
1302 };
1303
1304 let is_numeric = is_numeric_expr(&field_expr);
1307 let trimmed_field_expr = if is_numeric {
1308 field_expr.clone()
1309 } else {
1310 format!("String.trim({field_expr})")
1311 };
1312
1313 let field_is_array = assertion
1316 .field
1317 .as_deref()
1318 .filter(|f| !f.is_empty())
1319 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1320
1321 match assertion.assertion_type.as_str() {
1322 "equals" => {
1323 if let Some(expected) = &assertion.value {
1324 let elixir_val = json_to_elixir(expected);
1325 let is_string_expected = expected.is_string();
1327 if is_string_expected && !is_numeric {
1328 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1329 } else {
1330 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1331 }
1332 }
1333 }
1334 "contains" => {
1335 if let Some(expected) = &assertion.value {
1336 let elixir_val = json_to_elixir(expected);
1337 if field_is_array && expected.is_string() {
1338 let _ = writeln!(
1340 out,
1341 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1342 );
1343 } else {
1344 let _ = writeln!(
1346 out,
1347 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1348 );
1349 }
1350 }
1351 }
1352 "contains_all" => {
1353 if let Some(values) = &assertion.values {
1354 for val in values {
1355 let elixir_val = json_to_elixir(val);
1356 if field_is_array && val.is_string() {
1357 let _ = writeln!(
1358 out,
1359 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1360 );
1361 } else {
1362 let _ = writeln!(
1363 out,
1364 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1365 );
1366 }
1367 }
1368 }
1369 }
1370 "not_contains" => {
1371 if let Some(expected) = &assertion.value {
1372 let elixir_val = json_to_elixir(expected);
1373 if field_is_array && expected.is_string() {
1374 let _ = writeln!(
1375 out,
1376 " refute Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1377 );
1378 } else {
1379 let _ = writeln!(
1380 out,
1381 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1382 );
1383 }
1384 }
1385 }
1386 "not_empty" => {
1387 let _ = writeln!(out, " assert {field_expr} != \"\"");
1388 }
1389 "is_empty" => {
1390 if is_numeric {
1391 let _ = writeln!(out, " assert {field_expr} == 0");
1393 } else {
1394 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1396 }
1397 }
1398 "contains_any" => {
1399 if let Some(values) = &assertion.values {
1400 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1401 let list_str = items.join(", ");
1402 let _ = writeln!(
1403 out,
1404 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1405 );
1406 }
1407 }
1408 "greater_than" => {
1409 if let Some(val) = &assertion.value {
1410 let elixir_val = json_to_elixir(val);
1411 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
1412 }
1413 }
1414 "less_than" => {
1415 if let Some(val) = &assertion.value {
1416 let elixir_val = json_to_elixir(val);
1417 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
1418 }
1419 }
1420 "greater_than_or_equal" => {
1421 if let Some(val) = &assertion.value {
1422 let elixir_val = json_to_elixir(val);
1423 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
1424 }
1425 }
1426 "less_than_or_equal" => {
1427 if let Some(val) = &assertion.value {
1428 let elixir_val = json_to_elixir(val);
1429 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
1430 }
1431 }
1432 "starts_with" => {
1433 if let Some(expected) = &assertion.value {
1434 let elixir_val = json_to_elixir(expected);
1435 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
1436 }
1437 }
1438 "ends_with" => {
1439 if let Some(expected) = &assertion.value {
1440 let elixir_val = json_to_elixir(expected);
1441 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
1442 }
1443 }
1444 "min_length" => {
1445 if let Some(val) = &assertion.value {
1446 if let Some(n) = val.as_u64() {
1447 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1448 }
1449 }
1450 }
1451 "max_length" => {
1452 if let Some(val) = &assertion.value {
1453 if let Some(n) = val.as_u64() {
1454 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
1455 }
1456 }
1457 }
1458 "count_min" => {
1459 if let Some(val) = &assertion.value {
1460 if let Some(n) = val.as_u64() {
1461 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
1462 }
1463 }
1464 }
1465 "count_equals" => {
1466 if let Some(val) = &assertion.value {
1467 if let Some(n) = val.as_u64() {
1468 let _ = writeln!(out, " assert length({field_expr}) == {n}");
1469 }
1470 }
1471 }
1472 "is_true" => {
1473 let _ = writeln!(out, " assert {field_expr} == true");
1474 }
1475 "is_false" => {
1476 let _ = writeln!(out, " assert {field_expr} == false");
1477 }
1478 "method_result" => {
1479 if let Some(method_name) = &assertion.method {
1480 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1481 let check = assertion.check.as_deref().unwrap_or("is_true");
1482 match check {
1483 "equals" => {
1484 if let Some(val) = &assertion.value {
1485 let elixir_val = json_to_elixir(val);
1486 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
1487 }
1488 }
1489 "is_true" => {
1490 let _ = writeln!(out, " assert {call_expr} == true");
1491 }
1492 "is_false" => {
1493 let _ = writeln!(out, " assert {call_expr} == false");
1494 }
1495 "greater_than_or_equal" => {
1496 if let Some(val) = &assertion.value {
1497 let n = val.as_u64().unwrap_or(0);
1498 let _ = writeln!(out, " assert {call_expr} >= {n}");
1499 }
1500 }
1501 "count_min" => {
1502 if let Some(val) = &assertion.value {
1503 let n = val.as_u64().unwrap_or(0);
1504 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
1505 }
1506 }
1507 "contains" => {
1508 if let Some(val) = &assertion.value {
1509 let elixir_val = json_to_elixir(val);
1510 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
1511 }
1512 }
1513 "is_error" => {
1514 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
1515 }
1516 other_check => {
1517 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1518 }
1519 }
1520 } else {
1521 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1522 }
1523 }
1524 "matches_regex" => {
1525 if let Some(expected) = &assertion.value {
1526 let elixir_val = json_to_elixir(expected);
1527 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1528 }
1529 }
1530 "not_error" => {
1531 }
1533 "error" => {
1534 }
1536 other => {
1537 panic!("Elixir e2e generator: unsupported assertion type: {other}");
1538 }
1539 }
1540}
1541
1542fn build_elixir_method_call(
1545 result_var: &str,
1546 method_name: &str,
1547 args: Option<&serde_json::Value>,
1548 module_path: &str,
1549) -> String {
1550 match method_name {
1551 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1552 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1553 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1554 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1555 "contains_node_type" => {
1556 let node_type = args
1557 .and_then(|a| a.get("node_type"))
1558 .and_then(|v| v.as_str())
1559 .unwrap_or("");
1560 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1561 }
1562 "find_nodes_by_type" => {
1563 let node_type = args
1564 .and_then(|a| a.get("node_type"))
1565 .and_then(|v| v.as_str())
1566 .unwrap_or("");
1567 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1568 }
1569 "run_query" => {
1570 let query_source = args
1571 .and_then(|a| a.get("query_source"))
1572 .and_then(|v| v.as_str())
1573 .unwrap_or("");
1574 let language = args
1575 .and_then(|a| a.get("language"))
1576 .and_then(|v| v.as_str())
1577 .unwrap_or("");
1578 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1579 }
1580 _ => format!("{module_path}.{method_name}({result_var})"),
1581 }
1582}
1583
1584fn elixir_module_name(category: &str) -> String {
1586 use heck::ToUpperCamelCase;
1587 category.to_upper_camel_case()
1588}
1589
1590fn json_to_elixir(value: &serde_json::Value) -> String {
1592 match value {
1593 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1594 serde_json::Value::Bool(true) => "true".to_string(),
1595 serde_json::Value::Bool(false) => "false".to_string(),
1596 serde_json::Value::Number(n) => {
1597 let s = n.to_string().replace("e+", "e");
1601 if s.contains('e') && !s.contains('.') {
1602 s.replacen('e', ".0e", 1)
1604 } else {
1605 s
1606 }
1607 }
1608 serde_json::Value::Null => "nil".to_string(),
1609 serde_json::Value::Array(arr) => {
1610 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1611 format!("[{}]", items.join(", "))
1612 }
1613 serde_json::Value::Object(map) => {
1614 let entries: Vec<String> = map
1615 .iter()
1616 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1617 .collect();
1618 format!("%{{{}}}", entries.join(", "))
1619 }
1620 }
1621}
1622
1623fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1625 use std::fmt::Write as FmtWrite;
1626 let mut visitor_obj = String::new();
1627 let _ = writeln!(visitor_obj, "%{{");
1628 for (method_name, action) in &visitor_spec.callbacks {
1629 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1630 }
1631 let _ = writeln!(visitor_obj, " }}");
1632
1633 setup_lines.push(format!("visitor = {visitor_obj}"));
1634 "visitor".to_string()
1635}
1636
1637fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1639 use std::fmt::Write as FmtWrite;
1640
1641 let handle_method = format!("handle_{}", &method_name[6..]); let arg_binding = match action {
1651 CallbackAction::CustomTemplate { .. } => "args",
1652 _ => "_args",
1653 };
1654 let _ = writeln!(out, " :{handle_method} => fn({arg_binding}) ->");
1655 match action {
1656 CallbackAction::Skip => {
1657 let _ = writeln!(out, " :skip");
1658 }
1659 CallbackAction::Continue => {
1660 let _ = writeln!(out, " :continue");
1661 }
1662 CallbackAction::PreserveHtml => {
1663 let _ = writeln!(out, " :preserve_html");
1664 }
1665 CallbackAction::Custom { output } => {
1666 let escaped = escape_elixir(output);
1667 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1668 }
1669 CallbackAction::CustomTemplate { template } => {
1670 let expr = template_to_elixir_concat(template);
1674 let _ = writeln!(out, " {{:custom, {expr}}}");
1675 }
1676 }
1677 let _ = writeln!(out, " end,");
1678}
1679
1680fn template_to_elixir_concat(template: &str) -> String {
1685 let mut parts: Vec<String> = Vec::new();
1686 let mut static_buf = String::new();
1687 let mut chars = template.chars().peekable();
1688
1689 while let Some(ch) = chars.next() {
1690 if ch == '{' {
1691 let mut key = String::new();
1692 let mut closed = false;
1693 for kc in chars.by_ref() {
1694 if kc == '}' {
1695 closed = true;
1696 break;
1697 }
1698 key.push(kc);
1699 }
1700 if closed && !key.is_empty() {
1701 if !static_buf.is_empty() {
1702 let escaped = escape_elixir(&static_buf);
1703 parts.push(format!("\"{escaped}\""));
1704 static_buf.clear();
1705 }
1706 let escaped_key = escape_elixir(&key);
1707 parts.push(format!("Map.get(args, \"{escaped_key}\", \"\")"));
1708 } else {
1709 static_buf.push('{');
1710 static_buf.push_str(&key);
1711 if !closed {
1712 }
1714 }
1715 } else {
1716 static_buf.push(ch);
1717 }
1718 }
1719
1720 if !static_buf.is_empty() {
1721 let escaped = escape_elixir(&static_buf);
1722 parts.push(format!("\"{escaped}\""));
1723 }
1724
1725 if parts.is_empty() {
1726 return "\"\"".to_string();
1727 }
1728 parts.join(" <> ")
1729}
1730
1731fn fixture_has_elixir_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
1732 if fixture.is_http_test() {
1734 return false;
1735 }
1736 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1737 let elixir_override = call_config
1738 .overrides
1739 .get("elixir")
1740 .or_else(|| e2e_config.call.overrides.get("elixir"));
1741 if elixir_override.and_then(|o| o.client_factory.as_deref()).is_some() {
1743 return true;
1744 }
1745 let function_from_override = elixir_override.and_then(|o| o.function.as_deref());
1750
1751 function_from_override.is_some() || !call_config.function.is_empty()
1753}