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
1335#[allow(clippy::too_many_arguments)]
1336fn render_assertion(
1337 out: &mut String,
1338 assertion: &Assertion,
1339 result_var: &str,
1340 field_resolver: &FieldResolver,
1341 module_path: &str,
1342 fields_enum: &std::collections::HashSet<String>,
1343 per_call_enum_fields: &HashMap<String, String>,
1344 result_is_simple: bool,
1345) {
1346 if let Some(f) = &assertion.field {
1349 match f.as_str() {
1350 "chunks_have_content" => {
1351 let pred =
1352 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
1353 match assertion.assertion_type.as_str() {
1354 "is_true" => {
1355 let _ = writeln!(out, " assert {pred}");
1356 }
1357 "is_false" => {
1358 let _ = writeln!(out, " refute {pred}");
1359 }
1360 _ => {
1361 let _ = writeln!(
1362 out,
1363 " # skipped: unsupported assertion type on synthetic field '{f}'"
1364 );
1365 }
1366 }
1367 return;
1368 }
1369 "chunks_have_embeddings" => {
1370 let pred = format!(
1371 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
1372 );
1373 match assertion.assertion_type.as_str() {
1374 "is_true" => {
1375 let _ = writeln!(out, " assert {pred}");
1376 }
1377 "is_false" => {
1378 let _ = writeln!(out, " refute {pred}");
1379 }
1380 _ => {
1381 let _ = writeln!(
1382 out,
1383 " # skipped: unsupported assertion type on synthetic field '{f}'"
1384 );
1385 }
1386 }
1387 return;
1388 }
1389 "embeddings" => {
1393 match assertion.assertion_type.as_str() {
1394 "count_equals" => {
1395 if let Some(val) = &assertion.value {
1396 let ex_val = json_to_elixir(val);
1397 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
1398 }
1399 }
1400 "count_min" => {
1401 if let Some(val) = &assertion.value {
1402 let ex_val = json_to_elixir(val);
1403 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
1404 }
1405 }
1406 "not_empty" => {
1407 let _ = writeln!(out, " assert {result_var} != []");
1408 }
1409 "is_empty" => {
1410 let _ = writeln!(out, " assert {result_var} == []");
1411 }
1412 _ => {
1413 let _ = writeln!(
1414 out,
1415 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1416 );
1417 }
1418 }
1419 return;
1420 }
1421 "embedding_dimensions" => {
1422 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1423 match assertion.assertion_type.as_str() {
1424 "equals" => {
1425 if let Some(val) = &assertion.value {
1426 let ex_val = json_to_elixir(val);
1427 let _ = writeln!(out, " assert {expr} == {ex_val}");
1428 }
1429 }
1430 "greater_than" => {
1431 if let Some(val) = &assertion.value {
1432 let ex_val = json_to_elixir(val);
1433 let _ = writeln!(out, " assert {expr} > {ex_val}");
1434 }
1435 }
1436 _ => {
1437 let _ = writeln!(
1438 out,
1439 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1440 );
1441 }
1442 }
1443 return;
1444 }
1445 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1446 let pred = match f.as_str() {
1447 "embeddings_valid" => {
1448 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1449 }
1450 "embeddings_finite" => {
1451 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1452 }
1453 "embeddings_non_zero" => {
1454 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1455 }
1456 "embeddings_normalized" => {
1457 format!(
1458 "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)"
1459 )
1460 }
1461 _ => unreachable!(),
1462 };
1463 match assertion.assertion_type.as_str() {
1464 "is_true" => {
1465 let _ = writeln!(out, " assert {pred}");
1466 }
1467 "is_false" => {
1468 let _ = writeln!(out, " refute {pred}");
1469 }
1470 _ => {
1471 let _ = writeln!(
1472 out,
1473 " # skipped: unsupported assertion type on synthetic field '{f}'"
1474 );
1475 }
1476 }
1477 return;
1478 }
1479 "keywords" | "keywords_count" => {
1482 let _ = writeln!(
1483 out,
1484 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1485 );
1486 return;
1487 }
1488 _ => {}
1489 }
1490 }
1491
1492 if !result_is_simple {
1497 if let Some(f) = &assertion.field {
1498 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1499 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1500 return;
1501 }
1502 }
1503 }
1504
1505 let field_expr = if result_is_simple {
1509 result_var.to_string()
1510 } else {
1511 match &assertion.field {
1512 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1513 _ => result_var.to_string(),
1514 }
1515 };
1516
1517 let is_numeric = is_numeric_expr(&field_expr);
1520 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1527 let resolved = field_resolver.resolve(f);
1528 fields_enum.contains(f)
1529 || fields_enum.contains(resolved)
1530 || per_call_enum_fields.contains_key(f)
1531 || per_call_enum_fields.contains_key(resolved)
1532 });
1533 let coerced_field_expr = if field_is_enum {
1534 format!("to_string({field_expr})")
1535 } else {
1536 field_expr.clone()
1537 };
1538 let trimmed_field_expr = if is_numeric {
1539 field_expr.clone()
1540 } else {
1541 format!("String.trim({coerced_field_expr})")
1542 };
1543
1544 let field_is_array = assertion
1547 .field
1548 .as_deref()
1549 .filter(|f| !f.is_empty())
1550 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1551
1552 match assertion.assertion_type.as_str() {
1553 "equals" => {
1554 if let Some(expected) = &assertion.value {
1555 let elixir_val = json_to_elixir(expected);
1556 let is_string_expected = expected.is_string();
1558 if is_string_expected && !is_numeric {
1559 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1560 } else if field_is_enum {
1561 let _ = writeln!(out, " assert {coerced_field_expr} == {elixir_val}");
1562 } else {
1563 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1564 }
1565 }
1566 }
1567 "contains" => {
1568 if let Some(expected) = &assertion.value {
1569 let elixir_val = json_to_elixir(expected);
1570 if field_is_array && expected.is_string() {
1571 let _ = writeln!(
1573 out,
1574 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1575 );
1576 } else {
1577 let _ = writeln!(
1579 out,
1580 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1581 );
1582 }
1583 }
1584 }
1585 "contains_all" => {
1586 if let Some(values) = &assertion.values {
1587 for val in values {
1588 let elixir_val = json_to_elixir(val);
1589 if field_is_array && val.is_string() {
1590 let _ = writeln!(
1591 out,
1592 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1593 );
1594 } else {
1595 let _ = writeln!(
1596 out,
1597 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1598 );
1599 }
1600 }
1601 }
1602 }
1603 "not_contains" => {
1604 if let Some(expected) = &assertion.value {
1605 let elixir_val = json_to_elixir(expected);
1606 if field_is_array && expected.is_string() {
1607 let _ = writeln!(
1608 out,
1609 " refute Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1610 );
1611 } else {
1612 let _ = writeln!(
1613 out,
1614 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1615 );
1616 }
1617 }
1618 }
1619 "not_empty" => {
1620 let _ = writeln!(out, " assert {field_expr} != \"\"");
1621 }
1622 "is_empty" => {
1623 if is_numeric {
1624 let _ = writeln!(out, " assert {field_expr} == 0");
1626 } else {
1627 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1629 }
1630 }
1631 "contains_any" => {
1632 if let Some(values) = &assertion.values {
1633 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1634 let list_str = items.join(", ");
1635 let _ = writeln!(
1636 out,
1637 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1638 );
1639 }
1640 }
1641 "greater_than" => {
1642 if let Some(val) = &assertion.value {
1643 let elixir_val = json_to_elixir(val);
1644 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
1645 }
1646 }
1647 "less_than" => {
1648 if let Some(val) = &assertion.value {
1649 let elixir_val = json_to_elixir(val);
1650 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
1651 }
1652 }
1653 "greater_than_or_equal" => {
1654 if let Some(val) = &assertion.value {
1655 let elixir_val = json_to_elixir(val);
1656 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
1657 }
1658 }
1659 "less_than_or_equal" => {
1660 if let Some(val) = &assertion.value {
1661 let elixir_val = json_to_elixir(val);
1662 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
1663 }
1664 }
1665 "starts_with" => {
1666 if let Some(expected) = &assertion.value {
1667 let elixir_val = json_to_elixir(expected);
1668 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
1669 }
1670 }
1671 "ends_with" => {
1672 if let Some(expected) = &assertion.value {
1673 let elixir_val = json_to_elixir(expected);
1674 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
1675 }
1676 }
1677 "min_length" => {
1678 if let Some(val) = &assertion.value {
1679 if let Some(n) = val.as_u64() {
1680 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1681 }
1682 }
1683 }
1684 "max_length" => {
1685 if let Some(val) = &assertion.value {
1686 if let Some(n) = val.as_u64() {
1687 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
1688 }
1689 }
1690 }
1691 "count_min" => {
1692 if let Some(val) = &assertion.value {
1693 if let Some(n) = val.as_u64() {
1694 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
1695 }
1696 }
1697 }
1698 "count_equals" => {
1699 if let Some(val) = &assertion.value {
1700 if let Some(n) = val.as_u64() {
1701 let _ = writeln!(out, " assert length({field_expr}) == {n}");
1702 }
1703 }
1704 }
1705 "is_true" => {
1706 let _ = writeln!(out, " assert {field_expr} == true");
1707 }
1708 "is_false" => {
1709 let _ = writeln!(out, " assert {field_expr} == false");
1710 }
1711 "method_result" => {
1712 if let Some(method_name) = &assertion.method {
1713 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1714 let check = assertion.check.as_deref().unwrap_or("is_true");
1715 match check {
1716 "equals" => {
1717 if let Some(val) = &assertion.value {
1718 let elixir_val = json_to_elixir(val);
1719 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
1720 }
1721 }
1722 "is_true" => {
1723 let _ = writeln!(out, " assert {call_expr} == true");
1724 }
1725 "is_false" => {
1726 let _ = writeln!(out, " assert {call_expr} == false");
1727 }
1728 "greater_than_or_equal" => {
1729 if let Some(val) = &assertion.value {
1730 let n = val.as_u64().unwrap_or(0);
1731 let _ = writeln!(out, " assert {call_expr} >= {n}");
1732 }
1733 }
1734 "count_min" => {
1735 if let Some(val) = &assertion.value {
1736 let n = val.as_u64().unwrap_or(0);
1737 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
1738 }
1739 }
1740 "contains" => {
1741 if let Some(val) = &assertion.value {
1742 let elixir_val = json_to_elixir(val);
1743 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
1744 }
1745 }
1746 "is_error" => {
1747 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
1748 }
1749 other_check => {
1750 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1751 }
1752 }
1753 } else {
1754 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1755 }
1756 }
1757 "matches_regex" => {
1758 if let Some(expected) = &assertion.value {
1759 let elixir_val = json_to_elixir(expected);
1760 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1761 }
1762 }
1763 "not_error" => {
1764 }
1766 "error" => {
1767 }
1769 other => {
1770 panic!("Elixir e2e generator: unsupported assertion type: {other}");
1771 }
1772 }
1773}
1774
1775fn build_elixir_method_call(
1778 result_var: &str,
1779 method_name: &str,
1780 args: Option<&serde_json::Value>,
1781 module_path: &str,
1782) -> String {
1783 match method_name {
1784 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1785 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1786 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1787 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1788 "contains_node_type" => {
1789 let node_type = args
1790 .and_then(|a| a.get("node_type"))
1791 .and_then(|v| v.as_str())
1792 .unwrap_or("");
1793 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1794 }
1795 "find_nodes_by_type" => {
1796 let node_type = args
1797 .and_then(|a| a.get("node_type"))
1798 .and_then(|v| v.as_str())
1799 .unwrap_or("");
1800 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1801 }
1802 "run_query" => {
1803 let query_source = args
1804 .and_then(|a| a.get("query_source"))
1805 .and_then(|v| v.as_str())
1806 .unwrap_or("");
1807 let language = args
1808 .and_then(|a| a.get("language"))
1809 .and_then(|v| v.as_str())
1810 .unwrap_or("");
1811 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1812 }
1813 _ => format!("{module_path}.{method_name}({result_var})"),
1814 }
1815}
1816
1817fn elixir_module_name(category: &str) -> String {
1819 use heck::ToUpperCamelCase;
1820 category.to_upper_camel_case()
1821}
1822
1823fn json_to_elixir(value: &serde_json::Value) -> String {
1825 match value {
1826 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1827 serde_json::Value::Bool(true) => "true".to_string(),
1828 serde_json::Value::Bool(false) => "false".to_string(),
1829 serde_json::Value::Number(n) => {
1830 let s = n.to_string().replace("e+", "e");
1834 if s.contains('e') && !s.contains('.') {
1835 s.replacen('e', ".0e", 1)
1837 } else {
1838 s
1839 }
1840 }
1841 serde_json::Value::Null => "nil".to_string(),
1842 serde_json::Value::Array(arr) => {
1843 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1844 format!("[{}]", items.join(", "))
1845 }
1846 serde_json::Value::Object(map) => {
1847 let entries: Vec<String> = map
1848 .iter()
1849 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1850 .collect();
1851 format!("%{{{}}}", entries.join(", "))
1852 }
1853 }
1854}
1855
1856fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1858 use std::fmt::Write as FmtWrite;
1859 let mut visitor_obj = String::new();
1860 let _ = writeln!(visitor_obj, "%{{");
1861 for (method_name, action) in &visitor_spec.callbacks {
1862 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1863 }
1864 let _ = writeln!(visitor_obj, " }}");
1865
1866 setup_lines.push(format!("visitor = {visitor_obj}"));
1867 "visitor".to_string()
1868}
1869
1870fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1872 use std::fmt::Write as FmtWrite;
1873
1874 let handle_method = format!("handle_{}", &method_name[6..]); let arg_binding = match action {
1884 CallbackAction::CustomTemplate { .. } => "args",
1885 _ => "_args",
1886 };
1887 let _ = writeln!(out, " :{handle_method} => fn({arg_binding}) ->");
1888 match action {
1889 CallbackAction::Skip => {
1890 let _ = writeln!(out, " :skip");
1891 }
1892 CallbackAction::Continue => {
1893 let _ = writeln!(out, " :continue");
1894 }
1895 CallbackAction::PreserveHtml => {
1896 let _ = writeln!(out, " :preserve_html");
1897 }
1898 CallbackAction::Custom { output } => {
1899 let escaped = escape_elixir(output);
1900 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1901 }
1902 CallbackAction::CustomTemplate { template, .. } => {
1903 let expr = template_to_elixir_concat(template);
1907 let _ = writeln!(out, " {{:custom, {expr}}}");
1908 }
1909 }
1910 let _ = writeln!(out, " end,");
1911}
1912
1913fn template_to_elixir_concat(template: &str) -> String {
1918 let mut parts: Vec<String> = Vec::new();
1919 let mut static_buf = String::new();
1920 let mut chars = template.chars().peekable();
1921
1922 while let Some(ch) = chars.next() {
1923 if ch == '{' {
1924 let mut key = String::new();
1925 let mut closed = false;
1926 for kc in chars.by_ref() {
1927 if kc == '}' {
1928 closed = true;
1929 break;
1930 }
1931 key.push(kc);
1932 }
1933 if closed && !key.is_empty() {
1934 if !static_buf.is_empty() {
1935 let escaped = escape_elixir(&static_buf);
1936 parts.push(format!("\"{escaped}\""));
1937 static_buf.clear();
1938 }
1939 let escaped_key = escape_elixir(&key);
1940 parts.push(format!("Map.get(args, \"{escaped_key}\", \"\")"));
1941 } else {
1942 static_buf.push('{');
1943 static_buf.push_str(&key);
1944 if !closed {
1945 }
1947 }
1948 } else {
1949 static_buf.push(ch);
1950 }
1951 }
1952
1953 if !static_buf.is_empty() {
1954 let escaped = escape_elixir(&static_buf);
1955 parts.push(format!("\"{escaped}\""));
1956 }
1957
1958 if parts.is_empty() {
1959 return "\"\"".to_string();
1960 }
1961 parts.join(" <> ")
1962}
1963
1964fn fixture_has_elixir_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
1965 if fixture.is_http_test() {
1967 return false;
1968 }
1969 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
1970 let elixir_override = call_config
1971 .overrides
1972 .get("elixir")
1973 .or_else(|| e2e_config.call.overrides.get("elixir"));
1974 if elixir_override.and_then(|o| o.client_factory.as_deref()).is_some() {
1976 return true;
1977 }
1978 let function_from_override = elixir_override.and_then(|o| o.function.as_deref());
1983
1984 function_from_override.is_some() || !call_config.function.is_empty()
1986}