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