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 # Use a large line buffer (default 1024 truncates `MOCK_SERVERS={...}` lines for
196 # fixture sets with many host-root routes, splitting them into `:noeol` chunks
197 # that the prefix-match clauses below would never see).
198 {:line, 65_536},
199 args: [fixtures_dir]
200 ])
201 # Read startup lines: MOCK_SERVER_URL= then MOCK_SERVERS= (always emitted, possibly `{}`).
202 # The standalone mock-server prints noisy stderr lines BEFORE the stdout sentinels;
203 # selective receive ignores anything that doesn't match the two prefix patterns.
204 # Each iteration only halts after the MOCK_SERVERS= line is processed.
205 {url, _} =
206 Enum.reduce_while(1..16, {nil, port}, fn _, {url_acc, p} ->
207 receive do
208 {^p, {:data, {:eol, "MOCK_SERVER_URL=" <> u}}} ->
209 {:cont, {u, p}}
210
211 {^p, {:data, {:eol, "MOCK_SERVERS=" <> json_val}}} ->
212 System.put_env("MOCK_SERVERS", json_val)
213 case Jason.decode(json_val) do
214 {:ok, servers} ->
215 Enum.each(servers, fn {fid, furl} ->
216 System.put_env("MOCK_SERVER_#{String.upcase(fid)}", furl)
217 end)
218
219 _ ->
220 :ok
221 end
222
223 {:halt, {url_acc, p}}
224 after
225 30_000 ->
226 raise "mock-server startup timeout"
227 end
228 end)
229
230 if url != nil do
231 System.put_env("MOCK_SERVER_URL", url)
232 end
233end
234"#
235 .to_string()
236 } else {
237 "ExUnit.start()\n".to_string()
238 }
239}
240
241fn render_mix_exs(
242 pkg_name: &str,
243 pkg_path: &str,
244 dep_mode: crate::config::DependencyMode,
245 has_http_tests: bool,
246 has_nif_tests: bool,
247) -> String {
248 let mut out = String::new();
249 let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
250 let _ = writeln!(out, " use Mix.Project");
251 let _ = writeln!(out);
252 let _ = writeln!(out, " def project do");
253 let _ = writeln!(out, " [");
254 let _ = writeln!(out, " app: :e2e_elixir,");
255 let _ = writeln!(out, " version: \"0.1.0\",");
256 let _ = writeln!(out, " elixir: \"~> 1.14\",");
257 let _ = writeln!(out, " deps: deps()");
258 let _ = writeln!(out, " ]");
259 let _ = writeln!(out, " end");
260 let _ = writeln!(out);
261 let _ = writeln!(out, " defp deps do");
262 let _ = writeln!(out, " [");
263
264 let mut deps: Vec<String> = Vec::new();
266
267 if has_nif_tests && !pkg_path.is_empty() {
269 let pkg_atom = pkg_name;
270 let nif_dep = match dep_mode {
271 crate::config::DependencyMode::Local => {
272 format!(" {{:{pkg_atom}, path: \"{pkg_path}\"}}")
273 }
274 crate::config::DependencyMode::Registry => {
275 format!(" {{:{pkg_atom}, \"{pkg_path}\"}}")
277 }
278 };
279 deps.push(nif_dep);
280 deps.push(format!(
282 " {{:rustler_precompiled, \"{rp}\"}}",
283 rp = tv::hex::RUSTLER_PRECOMPILED
284 ));
285 deps.push(format!(
290 " {{:rustler, \"{rustler}\", runtime: false}}",
291 rustler = tv::hex::RUSTLER
292 ));
293 }
294
295 if has_http_tests {
297 deps.push(format!(" {{:req, \"{req}\"}}", req = tv::hex::REQ));
298 deps.push(format!(" {{:jason, \"{jason}\"}}", jason = tv::hex::JASON));
299 }
300
301 let _ = writeln!(out, "{}", deps.join(",\n"));
302 let _ = writeln!(out, " ]");
303 let _ = writeln!(out, " end");
304 let _ = writeln!(out, "end");
305 out
306}
307
308#[allow(clippy::too_many_arguments)]
309fn render_test_file(
310 category: &str,
311 fixtures: &[&Fixture],
312 e2e_config: &E2eConfig,
313 module_path: &str,
314 function_name: &str,
315 result_var: &str,
316 args: &[crate::config::ArgMapping],
317 field_resolver: &FieldResolver,
318 options_type: Option<&str>,
319 options_default_fn: Option<&str>,
320 enum_fields: &HashMap<String, String>,
321 handle_struct_type: Option<&str>,
322 handle_atom_list_fields: &std::collections::HashSet<String>,
323) -> String {
324 let mut out = String::new();
325 out.push_str(&hash::header(CommentStyle::Hash));
326 let _ = writeln!(out, "# E2e tests for category: {category}");
327 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
328
329 let has_http = fixtures.iter().any(|f| f.is_http_test());
331
332 let async_flag = if has_http { "true" } else { "false" };
335 let _ = writeln!(out, " use ExUnit.Case, async: {async_flag}");
336
337 if has_http {
338 let _ = writeln!(out);
339 let _ = writeln!(out, " defp mock_server_url do");
340 let _ = writeln!(
341 out,
342 " System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
343 );
344 let _ = writeln!(out, " end");
345 }
346
347 let has_array_contains = fixtures.iter().any(|fixture| {
350 fixture.assertions.iter().any(|a| {
351 matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
352 && a.field
353 .as_deref()
354 .is_some_and(|f| !f.is_empty() && field_resolver.is_array(field_resolver.resolve(f)))
355 })
356 });
357 if has_array_contains {
358 let _ = writeln!(out);
359 let _ = writeln!(out, " defp alef_e2e_item_texts(item) when is_binary(item), do: [item]");
360 let _ = writeln!(out, " defp alef_e2e_item_texts(item) do");
361 let _ = writeln!(out, " [:kind, :name, :signature, :path, :alias, :text, :source]");
362 let _ = writeln!(out, " |> Enum.filter(&Map.has_key?(item, &1))");
363 let _ = writeln!(out, " |> Enum.flat_map(fn attr ->");
364 let _ = writeln!(out, " case Map.get(item, attr) do");
365 let _ = writeln!(out, " nil -> []");
366 let _ = writeln!(
367 out,
368 " atom when is_atom(atom) -> [atom |> to_string() |> String.capitalize()]"
369 );
370 let _ = writeln!(out, " str -> [to_string(str)]");
371 let _ = writeln!(out, " end");
372 let _ = writeln!(out, " end)");
373 let _ = writeln!(out, " end");
374 }
375
376 let _ = writeln!(out);
377
378 for (i, fixture) in fixtures.iter().enumerate() {
379 if let Some(http) = &fixture.http {
380 render_http_test_case(&mut out, fixture, http);
381 } else {
382 render_test_case(
383 &mut out,
384 fixture,
385 e2e_config,
386 module_path,
387 function_name,
388 result_var,
389 args,
390 field_resolver,
391 options_type,
392 options_default_fn,
393 enum_fields,
394 handle_struct_type,
395 handle_atom_list_fields,
396 );
397 }
398 if i + 1 < fixtures.len() {
399 let _ = writeln!(out);
400 }
401 }
402
403 let _ = writeln!(out, "end");
404 out
405}
406
407const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
414
415const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
418
419struct ElixirTestClientRenderer<'a> {
423 fixture_id: &'a str,
426 expected_status: u16,
428}
429
430impl<'a> client::TestClientRenderer for ElixirTestClientRenderer<'a> {
431 fn language_name(&self) -> &'static str {
432 "elixir"
433 }
434
435 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
441 let escaped_description = description.replace('"', "\\\"");
442 let _ = writeln!(out, " describe \"{fn_name}\" do");
443 if skip_reason.is_some() {
444 let _ = writeln!(out, " @tag :skip");
445 }
446 let _ = writeln!(out, " test \"{escaped_description}\" do");
447 }
448
449 fn render_test_close(&self, out: &mut String) {
451 let _ = writeln!(out, " end");
452 let _ = writeln!(out, " end");
453 }
454
455 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
457 let method = ctx.method.to_lowercase();
458 let mut opts: Vec<String> = Vec::new();
459
460 if let Some(body) = ctx.body {
461 let elixir_val = json_to_elixir(body);
462 opts.push(format!("json: {elixir_val}"));
463 }
464
465 if !ctx.headers.is_empty() {
466 let header_pairs: Vec<String> = ctx
467 .headers
468 .iter()
469 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
470 .collect();
471 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
472 }
473
474 if !ctx.cookies.is_empty() {
475 let cookie_str = ctx
476 .cookies
477 .iter()
478 .map(|(k, v)| format!("{k}={v}"))
479 .collect::<Vec<_>>()
480 .join("; ");
481 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
482 }
483
484 if !ctx.query_params.is_empty() {
485 let pairs: Vec<String> = ctx
486 .query_params
487 .iter()
488 .map(|(k, v)| {
489 let val_str = match v {
490 serde_json::Value::String(s) => s.clone(),
491 other => other.to_string(),
492 };
493 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
494 })
495 .collect();
496 opts.push(format!("params: [{}]", pairs.join(", ")));
497 }
498
499 if (300..400).contains(&self.expected_status) {
502 opts.push("redirect: false".to_string());
503 }
504
505 let fixture_id = escape_elixir(self.fixture_id);
506 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{fixture_id}\"");
507
508 if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
509 if opts.is_empty() {
510 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
511 } else {
512 let opts_str = opts.join(", ");
513 let _ = writeln!(
514 out,
515 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
516 );
517 }
518 } else {
519 opts.insert(0, format!("method: :{method}"));
520 opts.insert(1, format!("url: {url_expr}"));
521 let opts_str = opts.join(", ");
522 let _ = writeln!(out, " {{:ok, response}} = Req.request({opts_str})");
523 }
524 }
525
526 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
527 let _ = writeln!(out, " assert {response_var}.status == {status}");
528 }
529
530 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
535 let header_key = name.to_lowercase();
536 if header_key == "connection" {
538 return;
539 }
540 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
541 let get_header_expr = format!(
542 "Enum.find_value({response_var}.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
543 );
544 match expected {
545 "<<present>>" => {
546 let _ = writeln!(out, " assert {get_header_expr} != nil");
547 }
548 "<<absent>>" => {
549 let _ = writeln!(out, " assert {get_header_expr} == nil");
550 }
551 "<<uuid>>" => {
552 let var = sanitize_ident(&header_key);
553 let _ = writeln!(out, " header_val_{var} = {get_header_expr}");
554 let _ = writeln!(
555 out,
556 " 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}))"
557 );
558 }
559 literal => {
560 let val_lit = format!("\"{}\"", escape_elixir(literal));
561 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
562 }
563 }
564 }
565
566 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
571 let elixir_val = json_to_elixir(expected);
572 match expected {
573 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
574 let _ = writeln!(
575 out,
576 " body_decoded = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
577 );
578 let _ = writeln!(out, " assert body_decoded == {elixir_val}");
579 }
580 _ => {
581 let _ = writeln!(out, " assert {response_var}.body == {elixir_val}");
582 }
583 }
584 }
585
586 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
588 if let Some(obj) = expected.as_object() {
589 let _ = writeln!(
590 out,
591 " decoded_body = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
592 );
593 for (key, val) in obj {
594 let key_lit = format!("\"{}\"", escape_elixir(key));
595 let elixir_val = json_to_elixir(val);
596 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
597 }
598 }
599 }
600
601 fn render_assert_validation_errors(
604 &self,
605 out: &mut String,
606 response_var: &str,
607 errors: &[ValidationErrorExpectation],
608 ) {
609 for err in errors {
610 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
611 let _ = writeln!(
612 out,
613 " assert String.contains?(Jason.encode!({response_var}.body), {msg_lit})"
614 );
615 }
616 }
617}
618
619fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
625 let method = http.request.method.to_uppercase();
626
627 if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
631 let test_name = sanitize_ident(&fixture.id);
632 let test_label = fixture.id.replace('"', "\\\"");
633 let path = &http.request.path;
634 let _ = writeln!(out, " describe \"{test_name}\" do");
635 let _ = writeln!(out, " @tag :skip");
636 let _ = writeln!(out, " test \"{method} {path} - {test_label}\" do");
637 let _ = writeln!(out, " end");
638 let _ = writeln!(out, " end");
639 return;
640 }
641
642 let renderer = ElixirTestClientRenderer {
643 fixture_id: &fixture.id,
644 expected_status: http.expected_response.status_code,
645 };
646 client::http_call::render_http_test(out, &renderer, fixture);
647}
648
649#[allow(clippy::too_many_arguments)]
654fn render_test_case(
655 out: &mut String,
656 fixture: &Fixture,
657 e2e_config: &E2eConfig,
658 default_module_path: &str,
659 default_function_name: &str,
660 default_result_var: &str,
661 args: &[crate::config::ArgMapping],
662 field_resolver: &FieldResolver,
663 options_type: Option<&str>,
664 options_default_fn: Option<&str>,
665 enum_fields: &HashMap<String, String>,
666 handle_struct_type: Option<&str>,
667 handle_atom_list_fields: &std::collections::HashSet<String>,
668) {
669 let test_name = sanitize_ident(&fixture.id);
670 let test_label = fixture.id.replace('"', "\\\"");
671
672 if fixture.mock_response.is_none() && !fixture_has_elixir_callable(fixture, e2e_config) {
678 let _ = writeln!(out, " describe \"{test_name}\" do");
679 let _ = writeln!(out, " @tag :skip");
680 let _ = writeln!(out, " test \"{test_label}\" do");
681 let _ = writeln!(
682 out,
683 " # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
684 );
685 let _ = writeln!(out, " :ok");
686 let _ = writeln!(out, " end");
687 let _ = writeln!(out, " end");
688 return;
689 }
690
691 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
693 let lang = "elixir";
694 let call_overrides = call_config.overrides.get(lang);
695
696 let base_fn = call_overrides
699 .and_then(|o| o.function.as_ref())
700 .cloned()
701 .unwrap_or_else(|| call_config.function.clone());
702 if base_fn.starts_with("batch_extract_") {
703 let _ = writeln!(
704 out,
705 " describe \"{test_name}\" do",
706 test_name = sanitize_ident(&fixture.id)
707 );
708 let _ = writeln!(out, " @tag :skip");
709 let _ = writeln!(
710 out,
711 " test \"{test_label}\" do",
712 test_label = fixture.id.replace('"', "\\\"")
713 );
714 let _ = writeln!(
715 out,
716 " # batch functions excluded from Elixir binding: unsafe NIF tuple marshalling"
717 );
718 let _ = writeln!(out, " :ok");
719 let _ = writeln!(out, " end");
720 let _ = writeln!(out, " end");
721 return;
722 }
723
724 let (module_path, function_name, result_var) = if fixture.call.is_some() {
727 let raw_module = call_overrides
728 .and_then(|o| o.module.as_ref())
729 .cloned()
730 .unwrap_or_else(|| call_config.module.clone());
731 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
732 {
733 raw_module.clone()
734 } else {
735 elixir_module_name(&raw_module)
736 };
737 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") && !base_fn.ends_with("_stream") {
738 format!("{base_fn}_async")
739 } else {
740 base_fn
741 };
742 (resolved_module, resolved_fn, call_config.result_var.clone())
743 } else {
744 (
745 default_module_path.to_string(),
746 default_function_name.to_string(),
747 default_result_var.to_string(),
748 )
749 };
750
751 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
752 let validation_creation_failure = expects_error && fixture.resolved_category() == "validation";
756
757 let (
759 effective_args,
760 effective_options_type,
761 effective_options_default_fn,
762 effective_enum_fields,
763 effective_handle_struct_type,
764 effective_handle_atom_list_fields,
765 );
766 let empty_enum_fields_local: HashMap<String, String>;
767 let empty_atom_fields_local: std::collections::HashSet<String>;
768 let (
769 resolved_args,
770 resolved_options_type,
771 resolved_options_default_fn,
772 resolved_enum_fields_ref,
773 resolved_handle_struct_type,
774 resolved_handle_atom_list_fields_ref,
775 ) = if fixture.call.is_some() {
776 let co = call_config.overrides.get(lang);
777 effective_args = call_config.args.as_slice();
778 effective_options_type = co.and_then(|o| o.options_type.as_deref());
779 effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
780 empty_enum_fields_local = HashMap::new();
781 effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
782 effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
783 empty_atom_fields_local = std::collections::HashSet::new();
784 effective_handle_atom_list_fields = co
785 .map(|o| &o.handle_atom_list_fields)
786 .unwrap_or(&empty_atom_fields_local);
787 (
788 effective_args,
789 effective_options_type,
790 effective_options_default_fn,
791 effective_enum_fields,
792 effective_handle_struct_type,
793 effective_handle_atom_list_fields,
794 )
795 } else {
796 (
797 args as &[_],
798 options_type,
799 options_default_fn,
800 enum_fields,
801 handle_struct_type,
802 handle_atom_list_fields,
803 )
804 };
805
806 let test_documents_path = e2e_config.test_documents_relative_from(0);
807 let (mut setup_lines, args_str) = build_args_and_setup(
808 &fixture.input,
809 resolved_args,
810 &module_path,
811 resolved_options_type,
812 resolved_options_default_fn,
813 resolved_enum_fields_ref,
814 fixture,
815 resolved_handle_struct_type,
816 resolved_handle_atom_list_fields_ref,
817 &test_documents_path,
818 );
819
820 let visitor_var = fixture
822 .visitor
823 .as_ref()
824 .map(|visitor_spec| build_elixir_visitor(&mut setup_lines, visitor_spec));
825
826 let final_args = if let Some(ref visitor_var) = visitor_var {
829 let parts: Vec<&str> = args_str.split(", ").collect();
833 if parts.len() == 2 && parts[1] == "nil" {
834 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
836 } else if parts.len() == 2 {
837 setup_lines.push(format!(
839 "{} = Map.put({}, :visitor, {})",
840 parts[1], parts[1], visitor_var
841 ));
842 args_str
843 } else if parts.len() == 1 {
844 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
846 } else {
847 args_str
848 }
849 } else {
850 args_str
851 };
852
853 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
855 e2e_config
856 .call
857 .overrides
858 .get("elixir")
859 .and_then(|o| o.client_factory.as_deref())
860 });
861
862 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
866 let final_args_with_extras = if extra_args.is_empty() {
867 final_args
868 } else if final_args.is_empty() {
869 extra_args.join(", ")
870 } else {
871 format!("{final_args}, {}", extra_args.join(", "))
872 };
873
874 let effective_args = if client_factory.is_some() {
876 if final_args_with_extras.is_empty() {
877 "client".to_string()
878 } else {
879 format!("client, {final_args_with_extras}")
880 }
881 } else {
882 final_args_with_extras
883 };
884
885 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
889 let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
890 let needs_api_key_skip = !has_mock && api_key_var_opt.is_some();
891 let needs_env_fallback = has_mock && api_key_var_opt.is_some();
894
895 let _ = writeln!(out, " describe \"{test_name}\" do");
896 let _ = writeln!(out, " test \"{test_label}\" do");
897
898 if needs_api_key_skip {
899 let api_key_var = api_key_var_opt.unwrap_or("");
900 let _ = writeln!(out, " if System.get_env(\"{api_key_var}\") in [nil, \"\"] do");
901 let _ = writeln!(out, " # {api_key_var} not set — skipping live smoke test");
902 let _ = writeln!(out, " :ok");
903 let _ = writeln!(out, " else");
904 }
905
906 if validation_creation_failure {
910 let mut emitted_error_assertion = false;
911 for line in &setup_lines {
912 if !emitted_error_assertion && line.starts_with("{:ok,") {
913 if let Some(rhs) = line.split_once('=').map(|x| x.1) {
914 let rhs = rhs.trim();
915 let _ = writeln!(out, " assert {{:error, _}} = {rhs}");
916 emitted_error_assertion = true;
917 } else {
918 let _ = writeln!(out, " {line}");
919 }
920 } else {
921 let _ = writeln!(out, " {line}");
922 }
923 }
924 if !emitted_error_assertion {
925 let _ = writeln!(
926 out,
927 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
928 );
929 }
930 if needs_api_key_skip {
931 let _ = writeln!(out, " end");
932 }
933 let _ = writeln!(out, " end");
934 let _ = writeln!(out, " end");
935 return;
936 }
937
938 if expects_error {
945 for line in &setup_lines {
946 let _ = writeln!(out, " {line}");
947 }
948 if let Some(factory) = client_factory {
949 let fixture_id = &fixture.id;
950 let base_url_expr = if fixture.has_host_root_route() {
951 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
952 format!(
953 "(System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
954 )
955 } else {
956 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
957 };
958 let _ = writeln!(
959 out,
960 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", {base_url_expr})"
961 );
962 }
963 let _ = writeln!(
964 out,
965 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
966 );
967 if needs_api_key_skip {
968 let _ = writeln!(out, " end");
969 }
970 let _ = writeln!(out, " end");
971 let _ = writeln!(out, " end");
972 return;
973 }
974
975 for line in &setup_lines {
976 let _ = writeln!(out, " {line}");
977 }
978
979 if let Some(factory) = client_factory {
981 let fixture_id = &fixture.id;
982 if needs_env_fallback {
983 let api_key_var = api_key_var_opt.unwrap_or("");
986 let mock_url_expr = if fixture.has_host_root_route() {
987 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
988 format!(
989 "System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\""
990 )
991 } else {
992 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
993 };
994 let _ = writeln!(out, " api_key_val = System.get_env(\"{api_key_var}\")");
995 let _ = writeln!(
996 out,
997 " {{api_key_val, base_url_val}} = if api_key_val && api_key_val != \"\" do"
998 );
999 let _ = writeln!(
1000 out,
1001 " IO.puts(\"{fixture_id}: using real API ({api_key_var} is set)\")"
1002 );
1003 let _ = writeln!(out, " {{api_key_val, nil}}");
1004 let _ = writeln!(out, " else");
1005 let _ = writeln!(
1006 out,
1007 " IO.puts(\"{fixture_id}: using mock server ({api_key_var} not set)\")"
1008 );
1009 let _ = writeln!(out, " {{nil, {mock_url_expr}}}");
1010 let _ = writeln!(out, " end");
1011 let _ = writeln!(
1012 out,
1013 " {{:ok, client}} = {module_path}.{factory}(api_key_val, base_url_val)"
1014 );
1015 } else {
1016 let base_url_expr = if fixture.has_host_root_route() {
1017 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1018 format!(
1019 "(System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
1020 )
1021 } else {
1022 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
1023 };
1024 let _ = writeln!(
1025 out,
1026 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", {base_url_expr})"
1027 );
1028 }
1029 }
1030
1031 let returns_result = call_overrides
1033 .and_then(|o| o.returns_result)
1034 .unwrap_or(call_config.returns_result || client_factory.is_some());
1035
1036 let result_is_simple = call_config.result_is_simple || call_overrides.is_some_and(|o| o.result_is_simple);
1041
1042 if returns_result {
1043 let _ = writeln!(
1044 out,
1045 " {{:ok, {result_var}}} = {module_path}.{function_name}({effective_args})"
1046 );
1047 } else {
1048 let _ = writeln!(
1050 out,
1051 " {result_var} = {module_path}.{function_name}({effective_args})"
1052 );
1053 }
1054
1055 for assertion in &fixture.assertions {
1056 render_assertion(
1057 out,
1058 assertion,
1059 &result_var,
1060 field_resolver,
1061 &module_path,
1062 &e2e_config.fields_enum,
1063 resolved_enum_fields_ref,
1064 result_is_simple,
1065 );
1066 }
1067
1068 if needs_api_key_skip {
1069 let _ = writeln!(out, " end");
1070 }
1071 let _ = writeln!(out, " end");
1072 let _ = writeln!(out, " end");
1073}
1074
1075#[allow(clippy::too_many_arguments)]
1079fn emit_elixir_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1081 if let Some(items) = arr.as_array() {
1082 let item_strs: Vec<String> = items
1083 .iter()
1084 .filter_map(|item| {
1085 if let Some(obj) = item.as_object() {
1086 match elem_type {
1087 "BatchBytesItem" => {
1088 let content = obj.get("content").and_then(|v| v.as_array());
1089 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1090 let content_code = if let Some(arr) = content {
1091 let bytes: Vec<String> =
1092 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1093 format!("<<{}>>", bytes.join(", "))
1094 } else {
1095 "<<>>".to_string()
1096 };
1097 Some(format!(
1098 "%BatchBytesItem{{content: {}, mime_type: \"{}\"}}",
1099 content_code, mime_type
1100 ))
1101 }
1102 "BatchFileItem" => {
1103 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1104 Some(format!("%BatchFileItem{{path: \"{}\"}}", path))
1105 }
1106 _ => None,
1107 }
1108 } else {
1109 None
1110 }
1111 })
1112 .collect();
1113 format!("[{}]", item_strs.join(", "))
1114 } else {
1115 "[]".to_string()
1116 }
1117}
1118
1119#[allow(clippy::too_many_arguments)]
1120fn build_args_and_setup(
1121 input: &serde_json::Value,
1122 args: &[crate::config::ArgMapping],
1123 module_path: &str,
1124 options_type: Option<&str>,
1125 options_default_fn: Option<&str>,
1126 enum_fields: &HashMap<String, String>,
1127 fixture: &crate::fixture::Fixture,
1128 _handle_struct_type: Option<&str>,
1129 _handle_atom_list_fields: &std::collections::HashSet<String>,
1130 test_documents_path: &str,
1131) -> (Vec<String>, String) {
1132 let fixture_id = &fixture.id;
1133 if args.is_empty() {
1134 let is_empty_input = match input {
1138 serde_json::Value::Null => true,
1139 serde_json::Value::Object(m) => m.is_empty(),
1140 _ => false,
1141 };
1142 if is_empty_input {
1143 return (Vec::new(), String::new());
1144 }
1145 return (Vec::new(), json_to_elixir(input));
1146 }
1147
1148 let mut setup_lines: Vec<String> = Vec::new();
1149 let mut parts: Vec<String> = Vec::new();
1150
1151 for arg in args {
1152 if arg.arg_type == "mock_url" {
1153 if fixture.has_host_root_route() {
1154 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1155 setup_lines.push(format!(
1156 "{} = System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1157 arg.name,
1158 ));
1159 } else {
1160 setup_lines.push(format!(
1161 "{} = (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1162 arg.name,
1163 ));
1164 }
1165 parts.push(arg.name.clone());
1166 continue;
1167 }
1168
1169 if arg.arg_type == "handle" {
1170 let constructor_name = format!("create_{}", arg.name.to_snake_case());
1174 let config_value = if arg.field == "input" {
1175 input
1176 } else {
1177 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1178 input.get(field).unwrap_or(&serde_json::Value::Null)
1179 };
1180 let name = &arg.name;
1181 if config_value.is_null()
1182 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1183 {
1184 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
1185 } else {
1186 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
1189 let escaped = escape_elixir(&json_str);
1190 setup_lines.push(format!("{name}_config = \"{escaped}\""));
1191 setup_lines.push(format!(
1192 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
1193 ));
1194 }
1195 parts.push(arg.name.clone());
1196 continue;
1197 }
1198
1199 let val = if arg.field == "input" {
1200 Some(input)
1201 } else {
1202 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1203 input.get(field)
1204 };
1205 match val {
1206 None | Some(serde_json::Value::Null) if arg.optional => {
1207 parts.push("nil".to_string());
1210 continue;
1211 }
1212 None | Some(serde_json::Value::Null) => {
1213 let default_val = match arg.arg_type.as_str() {
1215 "string" => "\"\"".to_string(),
1216 "int" | "integer" => "0".to_string(),
1217 "float" | "number" => "0.0".to_string(),
1218 "bool" | "boolean" => "false".to_string(),
1219 _ => "nil".to_string(),
1220 };
1221 parts.push(default_val);
1222 }
1223 Some(v) => {
1224 if arg.arg_type == "file_path" {
1227 if let Some(path_str) = v.as_str() {
1228 let full_path = format!("{test_documents_path}/{path_str}");
1229 parts.push(format!("\"{}\"", escape_elixir(&full_path)));
1230 continue;
1231 }
1232 }
1233 if arg.arg_type == "bytes" {
1236 if let Some(raw) = v.as_str() {
1237 let var_name = &arg.name;
1238 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
1239 parts.push(format!("\"{}\"", escape_elixir(raw)));
1241 } else {
1242 let first = raw.chars().next().unwrap_or('\0');
1243 let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
1244 && raw
1245 .find('/')
1246 .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
1247 if is_file_path {
1248 let full_path = format!("{test_documents_path}/{raw}");
1251 let escaped = escape_elixir(&full_path);
1252 setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
1253 parts.push(var_name.to_string());
1254 } else {
1255 setup_lines.push(format!(
1257 "{var_name} = Base.decode64!(\"{}\", padding: false)",
1258 escape_elixir(raw)
1259 ));
1260 parts.push(var_name.to_string());
1261 }
1262 }
1263 continue;
1264 }
1265 }
1266 if arg.arg_type == "json_object" && !v.is_null() {
1268 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
1269 (options_type, options_default_fn, v.as_object())
1270 {
1271 let options_var = "options";
1273 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
1274
1275 for (k, vv) in obj.iter() {
1277 let snake_key = k.to_snake_case();
1278 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1279 if let Some(s) = vv.as_str() {
1280 let snake_val = s.to_snake_case();
1281 format!(":{snake_val}")
1283 } else {
1284 json_to_elixir(vv)
1285 }
1286 } else {
1287 json_to_elixir(vv)
1288 };
1289 setup_lines.push(format!(
1290 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
1291 ));
1292 }
1293
1294 parts.push(options_var.to_string());
1296 continue;
1297 }
1298 if let Some(elem_type) = &arg.element_type {
1300 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1301 parts.push(emit_elixir_batch_item_array(v, elem_type));
1302 continue;
1303 }
1304 if v.is_array() {
1307 parts.push(json_to_elixir(v));
1308 continue;
1309 }
1310 }
1311 if !v.is_null() {
1315 let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
1316 let escaped = escape_elixir(&json_str);
1317 parts.push(format!("\"{escaped}\""));
1318 continue;
1319 }
1320 }
1321 parts.push(json_to_elixir(v));
1322 }
1323 }
1324 }
1325
1326 (setup_lines, parts.join(", "))
1327}
1328
1329fn is_numeric_expr(field_expr: &str) -> bool {
1332 field_expr.starts_with("length(")
1333}
1334
1335fn render_assertion(
1336 out: &mut String,
1337 assertion: &Assertion,
1338 result_var: &str,
1339 field_resolver: &FieldResolver,
1340 module_path: &str,
1341 fields_enum: &std::collections::HashSet<String>,
1342 per_call_enum_fields: &HashMap<String, String>,
1343 result_is_simple: bool,
1344) {
1345 if let Some(f) = &assertion.field {
1348 match f.as_str() {
1349 "chunks_have_content" => {
1350 let pred =
1351 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
1352 match assertion.assertion_type.as_str() {
1353 "is_true" => {
1354 let _ = writeln!(out, " assert {pred}");
1355 }
1356 "is_false" => {
1357 let _ = writeln!(out, " refute {pred}");
1358 }
1359 _ => {
1360 let _ = writeln!(
1361 out,
1362 " # skipped: unsupported assertion type on synthetic field '{f}'"
1363 );
1364 }
1365 }
1366 return;
1367 }
1368 "chunks_have_embeddings" => {
1369 let pred = format!(
1370 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
1371 );
1372 match assertion.assertion_type.as_str() {
1373 "is_true" => {
1374 let _ = writeln!(out, " assert {pred}");
1375 }
1376 "is_false" => {
1377 let _ = writeln!(out, " refute {pred}");
1378 }
1379 _ => {
1380 let _ = writeln!(
1381 out,
1382 " # skipped: unsupported assertion type on synthetic field '{f}'"
1383 );
1384 }
1385 }
1386 return;
1387 }
1388 "embeddings" => {
1392 match assertion.assertion_type.as_str() {
1393 "count_equals" => {
1394 if let Some(val) = &assertion.value {
1395 let ex_val = json_to_elixir(val);
1396 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
1397 }
1398 }
1399 "count_min" => {
1400 if let Some(val) = &assertion.value {
1401 let ex_val = json_to_elixir(val);
1402 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
1403 }
1404 }
1405 "not_empty" => {
1406 let _ = writeln!(out, " assert {result_var} != []");
1407 }
1408 "is_empty" => {
1409 let _ = writeln!(out, " assert {result_var} == []");
1410 }
1411 _ => {
1412 let _ = writeln!(
1413 out,
1414 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1415 );
1416 }
1417 }
1418 return;
1419 }
1420 "embedding_dimensions" => {
1421 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1422 match assertion.assertion_type.as_str() {
1423 "equals" => {
1424 if let Some(val) = &assertion.value {
1425 let ex_val = json_to_elixir(val);
1426 let _ = writeln!(out, " assert {expr} == {ex_val}");
1427 }
1428 }
1429 "greater_than" => {
1430 if let Some(val) = &assertion.value {
1431 let ex_val = json_to_elixir(val);
1432 let _ = writeln!(out, " assert {expr} > {ex_val}");
1433 }
1434 }
1435 _ => {
1436 let _ = writeln!(
1437 out,
1438 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1439 );
1440 }
1441 }
1442 return;
1443 }
1444 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1445 let pred = match f.as_str() {
1446 "embeddings_valid" => {
1447 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1448 }
1449 "embeddings_finite" => {
1450 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1451 }
1452 "embeddings_non_zero" => {
1453 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1454 }
1455 "embeddings_normalized" => {
1456 format!(
1457 "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)"
1458 )
1459 }
1460 _ => unreachable!(),
1461 };
1462 match assertion.assertion_type.as_str() {
1463 "is_true" => {
1464 let _ = writeln!(out, " assert {pred}");
1465 }
1466 "is_false" => {
1467 let _ = writeln!(out, " refute {pred}");
1468 }
1469 _ => {
1470 let _ = writeln!(
1471 out,
1472 " # skipped: unsupported assertion type on synthetic field '{f}'"
1473 );
1474 }
1475 }
1476 return;
1477 }
1478 "keywords" | "keywords_count" => {
1481 let _ = writeln!(
1482 out,
1483 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1484 );
1485 return;
1486 }
1487 _ => {}
1488 }
1489 }
1490
1491 if !result_is_simple {
1496 if let Some(f) = &assertion.field {
1497 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1498 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1499 return;
1500 }
1501 }
1502 }
1503
1504 let field_expr = if result_is_simple {
1508 result_var.to_string()
1509 } else {
1510 match &assertion.field {
1511 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1512 _ => result_var.to_string(),
1513 }
1514 };
1515
1516 let is_numeric = is_numeric_expr(&field_expr);
1519 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1526 let resolved = field_resolver.resolve(f);
1527 fields_enum.contains(f)
1528 || fields_enum.contains(resolved)
1529 || per_call_enum_fields.contains_key(f)
1530 || per_call_enum_fields.contains_key(resolved)
1531 });
1532 let coerced_field_expr = if field_is_enum {
1533 format!("to_string({field_expr})")
1534 } else {
1535 field_expr.clone()
1536 };
1537 let trimmed_field_expr = if is_numeric {
1538 field_expr.clone()
1539 } else {
1540 format!("String.trim({coerced_field_expr})")
1541 };
1542
1543 let field_is_array = assertion
1546 .field
1547 .as_deref()
1548 .filter(|f| !f.is_empty())
1549 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1550
1551 match assertion.assertion_type.as_str() {
1552 "equals" => {
1553 if let Some(expected) = &assertion.value {
1554 let elixir_val = json_to_elixir(expected);
1555 let is_string_expected = expected.is_string();
1557 if is_string_expected && !is_numeric {
1558 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1559 } else if field_is_enum {
1560 let _ = writeln!(out, " assert {coerced_field_expr} == {elixir_val}");
1561 } else {
1562 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1563 }
1564 }
1565 }
1566 "contains" => {
1567 if let Some(expected) = &assertion.value {
1568 let elixir_val = json_to_elixir(expected);
1569 if field_is_array && expected.is_string() {
1570 let _ = writeln!(
1572 out,
1573 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1574 );
1575 } else {
1576 let _ = writeln!(
1578 out,
1579 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1580 );
1581 }
1582 }
1583 }
1584 "contains_all" => {
1585 if let Some(values) = &assertion.values {
1586 for val in values {
1587 let elixir_val = json_to_elixir(val);
1588 if field_is_array && val.is_string() {
1589 let _ = writeln!(
1590 out,
1591 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1592 );
1593 } else {
1594 let _ = writeln!(
1595 out,
1596 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1597 );
1598 }
1599 }
1600 }
1601 }
1602 "not_contains" => {
1603 if let Some(expected) = &assertion.value {
1604 let elixir_val = json_to_elixir(expected);
1605 if field_is_array && expected.is_string() {
1606 let _ = writeln!(
1607 out,
1608 " refute Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1609 );
1610 } else {
1611 let _ = writeln!(
1612 out,
1613 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1614 );
1615 }
1616 }
1617 }
1618 "not_empty" => {
1619 let _ = writeln!(out, " assert {field_expr} != \"\"");
1620 }
1621 "is_empty" => {
1622 if is_numeric {
1623 let _ = writeln!(out, " assert {field_expr} == 0");
1625 } else {
1626 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1628 }
1629 }
1630 "contains_any" => {
1631 if let Some(values) = &assertion.values {
1632 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1633 let list_str = items.join(", ");
1634 let _ = writeln!(
1635 out,
1636 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1637 );
1638 }
1639 }
1640 "greater_than" => {
1641 if let Some(val) = &assertion.value {
1642 let elixir_val = json_to_elixir(val);
1643 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
1644 }
1645 }
1646 "less_than" => {
1647 if let Some(val) = &assertion.value {
1648 let elixir_val = json_to_elixir(val);
1649 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
1650 }
1651 }
1652 "greater_than_or_equal" => {
1653 if let Some(val) = &assertion.value {
1654 let elixir_val = json_to_elixir(val);
1655 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
1656 }
1657 }
1658 "less_than_or_equal" => {
1659 if let Some(val) = &assertion.value {
1660 let elixir_val = json_to_elixir(val);
1661 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
1662 }
1663 }
1664 "starts_with" => {
1665 if let Some(expected) = &assertion.value {
1666 let elixir_val = json_to_elixir(expected);
1667 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
1668 }
1669 }
1670 "ends_with" => {
1671 if let Some(expected) = &assertion.value {
1672 let elixir_val = json_to_elixir(expected);
1673 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
1674 }
1675 }
1676 "min_length" => {
1677 if let Some(val) = &assertion.value {
1678 if let Some(n) = val.as_u64() {
1679 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1680 }
1681 }
1682 }
1683 "max_length" => {
1684 if let Some(val) = &assertion.value {
1685 if let Some(n) = val.as_u64() {
1686 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
1687 }
1688 }
1689 }
1690 "count_min" => {
1691 if let Some(val) = &assertion.value {
1692 if let Some(n) = val.as_u64() {
1693 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
1694 }
1695 }
1696 }
1697 "count_equals" => {
1698 if let Some(val) = &assertion.value {
1699 if let Some(n) = val.as_u64() {
1700 let _ = writeln!(out, " assert length({field_expr}) == {n}");
1701 }
1702 }
1703 }
1704 "is_true" => {
1705 let _ = writeln!(out, " assert {field_expr} == true");
1706 }
1707 "is_false" => {
1708 let _ = writeln!(out, " assert {field_expr} == false");
1709 }
1710 "method_result" => {
1711 if let Some(method_name) = &assertion.method {
1712 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1713 let check = assertion.check.as_deref().unwrap_or("is_true");
1714 match check {
1715 "equals" => {
1716 if let Some(val) = &assertion.value {
1717 let elixir_val = json_to_elixir(val);
1718 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
1719 }
1720 }
1721 "is_true" => {
1722 let _ = writeln!(out, " assert {call_expr} == true");
1723 }
1724 "is_false" => {
1725 let _ = writeln!(out, " assert {call_expr} == false");
1726 }
1727 "greater_than_or_equal" => {
1728 if let Some(val) = &assertion.value {
1729 let n = val.as_u64().unwrap_or(0);
1730 let _ = writeln!(out, " assert {call_expr} >= {n}");
1731 }
1732 }
1733 "count_min" => {
1734 if let Some(val) = &assertion.value {
1735 let n = val.as_u64().unwrap_or(0);
1736 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
1737 }
1738 }
1739 "contains" => {
1740 if let Some(val) = &assertion.value {
1741 let elixir_val = json_to_elixir(val);
1742 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
1743 }
1744 }
1745 "is_error" => {
1746 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
1747 }
1748 other_check => {
1749 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1750 }
1751 }
1752 } else {
1753 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1754 }
1755 }
1756 "matches_regex" => {
1757 if let Some(expected) = &assertion.value {
1758 let elixir_val = json_to_elixir(expected);
1759 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1760 }
1761 }
1762 "not_error" => {
1763 }
1765 "error" => {
1766 }
1768 other => {
1769 panic!("Elixir e2e generator: unsupported assertion type: {other}");
1770 }
1771 }
1772}
1773
1774fn build_elixir_method_call(
1777 result_var: &str,
1778 method_name: &str,
1779 args: Option<&serde_json::Value>,
1780 module_path: &str,
1781) -> String {
1782 match method_name {
1783 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1784 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1785 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1786 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1787 "contains_node_type" => {
1788 let node_type = args
1789 .and_then(|a| a.get("node_type"))
1790 .and_then(|v| v.as_str())
1791 .unwrap_or("");
1792 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1793 }
1794 "find_nodes_by_type" => {
1795 let node_type = args
1796 .and_then(|a| a.get("node_type"))
1797 .and_then(|v| v.as_str())
1798 .unwrap_or("");
1799 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1800 }
1801 "run_query" => {
1802 let query_source = args
1803 .and_then(|a| a.get("query_source"))
1804 .and_then(|v| v.as_str())
1805 .unwrap_or("");
1806 let language = args
1807 .and_then(|a| a.get("language"))
1808 .and_then(|v| v.as_str())
1809 .unwrap_or("");
1810 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1811 }
1812 _ => format!("{module_path}.{method_name}({result_var})"),
1813 }
1814}
1815
1816fn elixir_module_name(category: &str) -> String {
1818 use heck::ToUpperCamelCase;
1819 category.to_upper_camel_case()
1820}
1821
1822fn json_to_elixir(value: &serde_json::Value) -> String {
1824 match value {
1825 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1826 serde_json::Value::Bool(true) => "true".to_string(),
1827 serde_json::Value::Bool(false) => "false".to_string(),
1828 serde_json::Value::Number(n) => {
1829 let s = n.to_string().replace("e+", "e");
1833 if s.contains('e') && !s.contains('.') {
1834 s.replacen('e', ".0e", 1)
1836 } else {
1837 s
1838 }
1839 }
1840 serde_json::Value::Null => "nil".to_string(),
1841 serde_json::Value::Array(arr) => {
1842 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1843 format!("[{}]", items.join(", "))
1844 }
1845 serde_json::Value::Object(map) => {
1846 let entries: Vec<String> = map
1847 .iter()
1848 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1849 .collect();
1850 format!("%{{{}}}", entries.join(", "))
1851 }
1852 }
1853}
1854
1855fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1857 use std::fmt::Write as FmtWrite;
1858 let mut visitor_obj = String::new();
1859 let _ = writeln!(visitor_obj, "%{{");
1860 for (method_name, action) in &visitor_spec.callbacks {
1861 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1862 }
1863 let _ = writeln!(visitor_obj, " }}");
1864
1865 setup_lines.push(format!("visitor = {visitor_obj}"));
1866 "visitor".to_string()
1867}
1868
1869fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1871 use std::fmt::Write as FmtWrite;
1872
1873 let handle_method = format!("handle_{}", &method_name[6..]); let arg_binding = match action {
1883 CallbackAction::CustomTemplate { .. } => "args",
1884 _ => "_args",
1885 };
1886 let _ = writeln!(out, " :{handle_method} => fn({arg_binding}) ->");
1887 match action {
1888 CallbackAction::Skip => {
1889 let _ = writeln!(out, " :skip");
1890 }
1891 CallbackAction::Continue => {
1892 let _ = writeln!(out, " :continue");
1893 }
1894 CallbackAction::PreserveHtml => {
1895 let _ = writeln!(out, " :preserve_html");
1896 }
1897 CallbackAction::Custom { output } => {
1898 let escaped = escape_elixir(output);
1899 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1900 }
1901 CallbackAction::CustomTemplate { template } => {
1902 let expr = template_to_elixir_concat(template);
1906 let _ = writeln!(out, " {{:custom, {expr}}}");
1907 }
1908 }
1909 let _ = writeln!(out, " end,");
1910}
1911
1912fn template_to_elixir_concat(template: &str) -> String {
1917 let mut parts: Vec<String> = Vec::new();
1918 let mut static_buf = String::new();
1919 let mut chars = template.chars().peekable();
1920
1921 while let Some(ch) = chars.next() {
1922 if ch == '{' {
1923 let mut key = String::new();
1924 let mut closed = false;
1925 for kc in chars.by_ref() {
1926 if kc == '}' {
1927 closed = true;
1928 break;
1929 }
1930 key.push(kc);
1931 }
1932 if closed && !key.is_empty() {
1933 if !static_buf.is_empty() {
1934 let escaped = escape_elixir(&static_buf);
1935 parts.push(format!("\"{escaped}\""));
1936 static_buf.clear();
1937 }
1938 let escaped_key = escape_elixir(&key);
1939 parts.push(format!("Map.get(args, \"{escaped_key}\", \"\")"));
1940 } else {
1941 static_buf.push('{');
1942 static_buf.push_str(&key);
1943 if !closed {
1944 }
1946 }
1947 } else {
1948 static_buf.push(ch);
1949 }
1950 }
1951
1952 if !static_buf.is_empty() {
1953 let escaped = escape_elixir(&static_buf);
1954 parts.push(format!("\"{escaped}\""));
1955 }
1956
1957 if parts.is_empty() {
1958 return "\"\"".to_string();
1959 }
1960 parts.join(" <> ")
1961}
1962
1963fn fixture_has_elixir_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
1964 if fixture.is_http_test() {
1966 return false;
1967 }
1968 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
1969 let elixir_override = call_config
1970 .overrides
1971 .get("elixir")
1972 .or_else(|| e2e_config.call.overrides.get("elixir"));
1973 if elixir_override.and_then(|o| o.client_factory.as_deref()).is_some() {
1975 return true;
1976 }
1977 let function_from_override = elixir_override.and_then(|o| o.function.as_deref());
1982
1983 function_from_override.is_some() || !call_config.function.is_empty()
1985}