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 needs_api_key_skip = fixture.mock_response.is_none()
889 && fixture.http.is_none()
890 && fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()).is_some();
891
892 let _ = writeln!(out, " describe \"{test_name}\" do");
893 let _ = writeln!(out, " test \"{test_label}\" do");
894
895 if needs_api_key_skip {
896 let api_key_var = fixture
897 .env
898 .as_ref()
899 .and_then(|e| e.api_key_var.as_deref())
900 .unwrap_or("");
901 let _ = writeln!(out, " if System.get_env(\"{api_key_var}\") in [nil, \"\"] do");
902 let _ = writeln!(out, " # {api_key_var} not set — skipping live smoke test");
903 let _ = writeln!(out, " :ok");
904 let _ = writeln!(out, " else");
905 }
906
907 if validation_creation_failure {
911 let mut emitted_error_assertion = false;
912 for line in &setup_lines {
913 if !emitted_error_assertion && line.starts_with("{:ok,") {
914 if let Some(rhs) = line.split_once('=').map(|x| x.1) {
915 let rhs = rhs.trim();
916 let _ = writeln!(out, " assert {{:error, _}} = {rhs}");
917 emitted_error_assertion = true;
918 } else {
919 let _ = writeln!(out, " {line}");
920 }
921 } else {
922 let _ = writeln!(out, " {line}");
923 }
924 }
925 if !emitted_error_assertion {
926 let _ = writeln!(
927 out,
928 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
929 );
930 }
931 if needs_api_key_skip {
932 let _ = writeln!(out, " end");
933 }
934 let _ = writeln!(out, " end");
935 let _ = writeln!(out, " end");
936 return;
937 }
938
939 if expects_error {
946 for line in &setup_lines {
947 let _ = writeln!(out, " {line}");
948 }
949 if let Some(factory) = client_factory {
950 let fixture_id = &fixture.id;
951 let base_url_expr = if fixture.has_host_root_route() {
952 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
953 format!(
954 "(System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
955 )
956 } else {
957 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
958 };
959 let _ = writeln!(
960 out,
961 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", {base_url_expr})"
962 );
963 }
964 let _ = writeln!(
965 out,
966 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
967 );
968 if needs_api_key_skip {
969 let _ = writeln!(out, " end");
970 }
971 let _ = writeln!(out, " end");
972 let _ = writeln!(out, " end");
973 return;
974 }
975
976 for line in &setup_lines {
977 let _ = writeln!(out, " {line}");
978 }
979
980 if let Some(factory) = client_factory {
982 let fixture_id = &fixture.id;
983 let base_url_expr = if fixture.has_host_root_route() {
984 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
985 format!(
986 "(System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
987 )
988 } else {
989 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
990 };
991 let _ = writeln!(
992 out,
993 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", {base_url_expr})"
994 );
995 }
996
997 let returns_result = call_overrides
999 .and_then(|o| o.returns_result)
1000 .unwrap_or(call_config.returns_result || client_factory.is_some());
1001
1002 let result_is_simple = call_config.result_is_simple || call_overrides.is_some_and(|o| o.result_is_simple);
1007
1008 if returns_result {
1009 let _ = writeln!(
1010 out,
1011 " {{:ok, {result_var}}} = {module_path}.{function_name}({effective_args})"
1012 );
1013 } else {
1014 let _ = writeln!(
1016 out,
1017 " {result_var} = {module_path}.{function_name}({effective_args})"
1018 );
1019 }
1020
1021 for assertion in &fixture.assertions {
1022 render_assertion(
1023 out,
1024 assertion,
1025 &result_var,
1026 field_resolver,
1027 &module_path,
1028 &e2e_config.fields_enum,
1029 resolved_enum_fields_ref,
1030 result_is_simple,
1031 );
1032 }
1033
1034 if needs_api_key_skip {
1035 let _ = writeln!(out, " end");
1036 }
1037 let _ = writeln!(out, " end");
1038 let _ = writeln!(out, " end");
1039}
1040
1041#[allow(clippy::too_many_arguments)]
1045fn emit_elixir_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1047 if let Some(items) = arr.as_array() {
1048 let item_strs: Vec<String> = items
1049 .iter()
1050 .filter_map(|item| {
1051 if let Some(obj) = item.as_object() {
1052 match elem_type {
1053 "BatchBytesItem" => {
1054 let content = obj.get("content").and_then(|v| v.as_array());
1055 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1056 let content_code = if let Some(arr) = content {
1057 let bytes: Vec<String> =
1058 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1059 format!("<<{}>>", bytes.join(", "))
1060 } else {
1061 "<<>>".to_string()
1062 };
1063 Some(format!(
1064 "%BatchBytesItem{{content: {}, mime_type: \"{}\"}}",
1065 content_code, mime_type
1066 ))
1067 }
1068 "BatchFileItem" => {
1069 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1070 Some(format!("%BatchFileItem{{path: \"{}\"}}", path))
1071 }
1072 _ => None,
1073 }
1074 } else {
1075 None
1076 }
1077 })
1078 .collect();
1079 format!("[{}]", item_strs.join(", "))
1080 } else {
1081 "[]".to_string()
1082 }
1083}
1084
1085#[allow(clippy::too_many_arguments)]
1086fn build_args_and_setup(
1087 input: &serde_json::Value,
1088 args: &[crate::config::ArgMapping],
1089 module_path: &str,
1090 options_type: Option<&str>,
1091 options_default_fn: Option<&str>,
1092 enum_fields: &HashMap<String, String>,
1093 fixture: &crate::fixture::Fixture,
1094 _handle_struct_type: Option<&str>,
1095 _handle_atom_list_fields: &std::collections::HashSet<String>,
1096 test_documents_path: &str,
1097) -> (Vec<String>, String) {
1098 let fixture_id = &fixture.id;
1099 if args.is_empty() {
1100 let is_empty_input = match input {
1104 serde_json::Value::Null => true,
1105 serde_json::Value::Object(m) => m.is_empty(),
1106 _ => false,
1107 };
1108 if is_empty_input {
1109 return (Vec::new(), String::new());
1110 }
1111 return (Vec::new(), json_to_elixir(input));
1112 }
1113
1114 let mut setup_lines: Vec<String> = Vec::new();
1115 let mut parts: Vec<String> = Vec::new();
1116
1117 for arg in args {
1118 if arg.arg_type == "mock_url" {
1119 if fixture.has_host_root_route() {
1120 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1121 setup_lines.push(format!(
1122 "{} = System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1123 arg.name,
1124 ));
1125 } else {
1126 setup_lines.push(format!(
1127 "{} = (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1128 arg.name,
1129 ));
1130 }
1131 parts.push(arg.name.clone());
1132 continue;
1133 }
1134
1135 if arg.arg_type == "handle" {
1136 let constructor_name = format!("create_{}", arg.name.to_snake_case());
1140 let config_value = if arg.field == "input" {
1141 input
1142 } else {
1143 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1144 input.get(field).unwrap_or(&serde_json::Value::Null)
1145 };
1146 let name = &arg.name;
1147 if config_value.is_null()
1148 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1149 {
1150 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
1151 } else {
1152 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
1155 let escaped = escape_elixir(&json_str);
1156 setup_lines.push(format!("{name}_config = \"{escaped}\""));
1157 setup_lines.push(format!(
1158 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
1159 ));
1160 }
1161 parts.push(arg.name.clone());
1162 continue;
1163 }
1164
1165 let val = if arg.field == "input" {
1166 Some(input)
1167 } else {
1168 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1169 input.get(field)
1170 };
1171 match val {
1172 None | Some(serde_json::Value::Null) if arg.optional => {
1173 parts.push("nil".to_string());
1176 continue;
1177 }
1178 None | Some(serde_json::Value::Null) => {
1179 let default_val = match arg.arg_type.as_str() {
1181 "string" => "\"\"".to_string(),
1182 "int" | "integer" => "0".to_string(),
1183 "float" | "number" => "0.0".to_string(),
1184 "bool" | "boolean" => "false".to_string(),
1185 _ => "nil".to_string(),
1186 };
1187 parts.push(default_val);
1188 }
1189 Some(v) => {
1190 if arg.arg_type == "file_path" {
1193 if let Some(path_str) = v.as_str() {
1194 let full_path = format!("{test_documents_path}/{path_str}");
1195 parts.push(format!("\"{}\"", escape_elixir(&full_path)));
1196 continue;
1197 }
1198 }
1199 if arg.arg_type == "bytes" {
1202 if let Some(raw) = v.as_str() {
1203 let var_name = &arg.name;
1204 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
1205 parts.push(format!("\"{}\"", escape_elixir(raw)));
1207 } else {
1208 let first = raw.chars().next().unwrap_or('\0');
1209 let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
1210 && raw
1211 .find('/')
1212 .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
1213 if is_file_path {
1214 let full_path = format!("{test_documents_path}/{raw}");
1217 let escaped = escape_elixir(&full_path);
1218 setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
1219 parts.push(var_name.to_string());
1220 } else {
1221 setup_lines.push(format!(
1223 "{var_name} = Base.decode64!(\"{}\", padding: false)",
1224 escape_elixir(raw)
1225 ));
1226 parts.push(var_name.to_string());
1227 }
1228 }
1229 continue;
1230 }
1231 }
1232 if arg.arg_type == "json_object" && !v.is_null() {
1234 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
1235 (options_type, options_default_fn, v.as_object())
1236 {
1237 let options_var = "options";
1239 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
1240
1241 for (k, vv) in obj.iter() {
1243 let snake_key = k.to_snake_case();
1244 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1245 if let Some(s) = vv.as_str() {
1246 let snake_val = s.to_snake_case();
1247 format!(":{snake_val}")
1249 } else {
1250 json_to_elixir(vv)
1251 }
1252 } else {
1253 json_to_elixir(vv)
1254 };
1255 setup_lines.push(format!(
1256 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
1257 ));
1258 }
1259
1260 parts.push(options_var.to_string());
1262 continue;
1263 }
1264 if let Some(elem_type) = &arg.element_type {
1266 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1267 parts.push(emit_elixir_batch_item_array(v, elem_type));
1268 continue;
1269 }
1270 if v.is_array() {
1273 parts.push(json_to_elixir(v));
1274 continue;
1275 }
1276 }
1277 if !v.is_null() {
1281 let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
1282 let escaped = escape_elixir(&json_str);
1283 parts.push(format!("\"{escaped}\""));
1284 continue;
1285 }
1286 }
1287 parts.push(json_to_elixir(v));
1288 }
1289 }
1290 }
1291
1292 (setup_lines, parts.join(", "))
1293}
1294
1295fn is_numeric_expr(field_expr: &str) -> bool {
1298 field_expr.starts_with("length(")
1299}
1300
1301fn render_assertion(
1302 out: &mut String,
1303 assertion: &Assertion,
1304 result_var: &str,
1305 field_resolver: &FieldResolver,
1306 module_path: &str,
1307 fields_enum: &std::collections::HashSet<String>,
1308 per_call_enum_fields: &HashMap<String, String>,
1309 result_is_simple: bool,
1310) {
1311 if let Some(f) = &assertion.field {
1314 match f.as_str() {
1315 "chunks_have_content" => {
1316 let pred =
1317 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
1318 match assertion.assertion_type.as_str() {
1319 "is_true" => {
1320 let _ = writeln!(out, " assert {pred}");
1321 }
1322 "is_false" => {
1323 let _ = writeln!(out, " refute {pred}");
1324 }
1325 _ => {
1326 let _ = writeln!(
1327 out,
1328 " # skipped: unsupported assertion type on synthetic field '{f}'"
1329 );
1330 }
1331 }
1332 return;
1333 }
1334 "chunks_have_embeddings" => {
1335 let pred = format!(
1336 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
1337 );
1338 match assertion.assertion_type.as_str() {
1339 "is_true" => {
1340 let _ = writeln!(out, " assert {pred}");
1341 }
1342 "is_false" => {
1343 let _ = writeln!(out, " refute {pred}");
1344 }
1345 _ => {
1346 let _ = writeln!(
1347 out,
1348 " # skipped: unsupported assertion type on synthetic field '{f}'"
1349 );
1350 }
1351 }
1352 return;
1353 }
1354 "embeddings" => {
1358 match assertion.assertion_type.as_str() {
1359 "count_equals" => {
1360 if let Some(val) = &assertion.value {
1361 let ex_val = json_to_elixir(val);
1362 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
1363 }
1364 }
1365 "count_min" => {
1366 if let Some(val) = &assertion.value {
1367 let ex_val = json_to_elixir(val);
1368 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
1369 }
1370 }
1371 "not_empty" => {
1372 let _ = writeln!(out, " assert {result_var} != []");
1373 }
1374 "is_empty" => {
1375 let _ = writeln!(out, " assert {result_var} == []");
1376 }
1377 _ => {
1378 let _ = writeln!(
1379 out,
1380 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1381 );
1382 }
1383 }
1384 return;
1385 }
1386 "embedding_dimensions" => {
1387 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1388 match assertion.assertion_type.as_str() {
1389 "equals" => {
1390 if let Some(val) = &assertion.value {
1391 let ex_val = json_to_elixir(val);
1392 let _ = writeln!(out, " assert {expr} == {ex_val}");
1393 }
1394 }
1395 "greater_than" => {
1396 if let Some(val) = &assertion.value {
1397 let ex_val = json_to_elixir(val);
1398 let _ = writeln!(out, " assert {expr} > {ex_val}");
1399 }
1400 }
1401 _ => {
1402 let _ = writeln!(
1403 out,
1404 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1405 );
1406 }
1407 }
1408 return;
1409 }
1410 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1411 let pred = match f.as_str() {
1412 "embeddings_valid" => {
1413 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1414 }
1415 "embeddings_finite" => {
1416 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1417 }
1418 "embeddings_non_zero" => {
1419 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1420 }
1421 "embeddings_normalized" => {
1422 format!(
1423 "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)"
1424 )
1425 }
1426 _ => unreachable!(),
1427 };
1428 match assertion.assertion_type.as_str() {
1429 "is_true" => {
1430 let _ = writeln!(out, " assert {pred}");
1431 }
1432 "is_false" => {
1433 let _ = writeln!(out, " refute {pred}");
1434 }
1435 _ => {
1436 let _ = writeln!(
1437 out,
1438 " # skipped: unsupported assertion type on synthetic field '{f}'"
1439 );
1440 }
1441 }
1442 return;
1443 }
1444 "keywords" | "keywords_count" => {
1447 let _ = writeln!(
1448 out,
1449 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1450 );
1451 return;
1452 }
1453 _ => {}
1454 }
1455 }
1456
1457 if !result_is_simple {
1462 if let Some(f) = &assertion.field {
1463 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1464 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1465 return;
1466 }
1467 }
1468 }
1469
1470 let field_expr = if result_is_simple {
1474 result_var.to_string()
1475 } else {
1476 match &assertion.field {
1477 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1478 _ => result_var.to_string(),
1479 }
1480 };
1481
1482 let is_numeric = is_numeric_expr(&field_expr);
1485 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1492 let resolved = field_resolver.resolve(f);
1493 fields_enum.contains(f)
1494 || fields_enum.contains(resolved)
1495 || per_call_enum_fields.contains_key(f)
1496 || per_call_enum_fields.contains_key(resolved)
1497 });
1498 let coerced_field_expr = if field_is_enum {
1499 format!("to_string({field_expr})")
1500 } else {
1501 field_expr.clone()
1502 };
1503 let trimmed_field_expr = if is_numeric {
1504 field_expr.clone()
1505 } else {
1506 format!("String.trim({coerced_field_expr})")
1507 };
1508
1509 let field_is_array = assertion
1512 .field
1513 .as_deref()
1514 .filter(|f| !f.is_empty())
1515 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1516
1517 match assertion.assertion_type.as_str() {
1518 "equals" => {
1519 if let Some(expected) = &assertion.value {
1520 let elixir_val = json_to_elixir(expected);
1521 let is_string_expected = expected.is_string();
1523 if is_string_expected && !is_numeric {
1524 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1525 } else if field_is_enum {
1526 let _ = writeln!(out, " assert {coerced_field_expr} == {elixir_val}");
1527 } else {
1528 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1529 }
1530 }
1531 }
1532 "contains" => {
1533 if let Some(expected) = &assertion.value {
1534 let elixir_val = json_to_elixir(expected);
1535 if field_is_array && expected.is_string() {
1536 let _ = writeln!(
1538 out,
1539 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1540 );
1541 } else {
1542 let _ = writeln!(
1544 out,
1545 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1546 );
1547 }
1548 }
1549 }
1550 "contains_all" => {
1551 if let Some(values) = &assertion.values {
1552 for val in values {
1553 let elixir_val = json_to_elixir(val);
1554 if field_is_array && val.is_string() {
1555 let _ = writeln!(
1556 out,
1557 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1558 );
1559 } else {
1560 let _ = writeln!(
1561 out,
1562 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1563 );
1564 }
1565 }
1566 }
1567 }
1568 "not_contains" => {
1569 if let Some(expected) = &assertion.value {
1570 let elixir_val = json_to_elixir(expected);
1571 if field_is_array && expected.is_string() {
1572 let _ = writeln!(
1573 out,
1574 " refute 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!(
1578 out,
1579 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1580 );
1581 }
1582 }
1583 }
1584 "not_empty" => {
1585 let _ = writeln!(out, " assert {field_expr} != \"\"");
1586 }
1587 "is_empty" => {
1588 if is_numeric {
1589 let _ = writeln!(out, " assert {field_expr} == 0");
1591 } else {
1592 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1594 }
1595 }
1596 "contains_any" => {
1597 if let Some(values) = &assertion.values {
1598 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1599 let list_str = items.join(", ");
1600 let _ = writeln!(
1601 out,
1602 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1603 );
1604 }
1605 }
1606 "greater_than" => {
1607 if let Some(val) = &assertion.value {
1608 let elixir_val = json_to_elixir(val);
1609 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
1610 }
1611 }
1612 "less_than" => {
1613 if let Some(val) = &assertion.value {
1614 let elixir_val = json_to_elixir(val);
1615 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
1616 }
1617 }
1618 "greater_than_or_equal" => {
1619 if let Some(val) = &assertion.value {
1620 let elixir_val = json_to_elixir(val);
1621 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
1622 }
1623 }
1624 "less_than_or_equal" => {
1625 if let Some(val) = &assertion.value {
1626 let elixir_val = json_to_elixir(val);
1627 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
1628 }
1629 }
1630 "starts_with" => {
1631 if let Some(expected) = &assertion.value {
1632 let elixir_val = json_to_elixir(expected);
1633 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
1634 }
1635 }
1636 "ends_with" => {
1637 if let Some(expected) = &assertion.value {
1638 let elixir_val = json_to_elixir(expected);
1639 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
1640 }
1641 }
1642 "min_length" => {
1643 if let Some(val) = &assertion.value {
1644 if let Some(n) = val.as_u64() {
1645 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1646 }
1647 }
1648 }
1649 "max_length" => {
1650 if let Some(val) = &assertion.value {
1651 if let Some(n) = val.as_u64() {
1652 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
1653 }
1654 }
1655 }
1656 "count_min" => {
1657 if let Some(val) = &assertion.value {
1658 if let Some(n) = val.as_u64() {
1659 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
1660 }
1661 }
1662 }
1663 "count_equals" => {
1664 if let Some(val) = &assertion.value {
1665 if let Some(n) = val.as_u64() {
1666 let _ = writeln!(out, " assert length({field_expr}) == {n}");
1667 }
1668 }
1669 }
1670 "is_true" => {
1671 let _ = writeln!(out, " assert {field_expr} == true");
1672 }
1673 "is_false" => {
1674 let _ = writeln!(out, " assert {field_expr} == false");
1675 }
1676 "method_result" => {
1677 if let Some(method_name) = &assertion.method {
1678 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1679 let check = assertion.check.as_deref().unwrap_or("is_true");
1680 match check {
1681 "equals" => {
1682 if let Some(val) = &assertion.value {
1683 let elixir_val = json_to_elixir(val);
1684 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
1685 }
1686 }
1687 "is_true" => {
1688 let _ = writeln!(out, " assert {call_expr} == true");
1689 }
1690 "is_false" => {
1691 let _ = writeln!(out, " assert {call_expr} == false");
1692 }
1693 "greater_than_or_equal" => {
1694 if let Some(val) = &assertion.value {
1695 let n = val.as_u64().unwrap_or(0);
1696 let _ = writeln!(out, " assert {call_expr} >= {n}");
1697 }
1698 }
1699 "count_min" => {
1700 if let Some(val) = &assertion.value {
1701 let n = val.as_u64().unwrap_or(0);
1702 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
1703 }
1704 }
1705 "contains" => {
1706 if let Some(val) = &assertion.value {
1707 let elixir_val = json_to_elixir(val);
1708 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
1709 }
1710 }
1711 "is_error" => {
1712 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
1713 }
1714 other_check => {
1715 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1716 }
1717 }
1718 } else {
1719 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1720 }
1721 }
1722 "matches_regex" => {
1723 if let Some(expected) = &assertion.value {
1724 let elixir_val = json_to_elixir(expected);
1725 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1726 }
1727 }
1728 "not_error" => {
1729 }
1731 "error" => {
1732 }
1734 other => {
1735 panic!("Elixir e2e generator: unsupported assertion type: {other}");
1736 }
1737 }
1738}
1739
1740fn build_elixir_method_call(
1743 result_var: &str,
1744 method_name: &str,
1745 args: Option<&serde_json::Value>,
1746 module_path: &str,
1747) -> String {
1748 match method_name {
1749 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1750 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1751 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1752 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1753 "contains_node_type" => {
1754 let node_type = args
1755 .and_then(|a| a.get("node_type"))
1756 .and_then(|v| v.as_str())
1757 .unwrap_or("");
1758 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1759 }
1760 "find_nodes_by_type" => {
1761 let node_type = args
1762 .and_then(|a| a.get("node_type"))
1763 .and_then(|v| v.as_str())
1764 .unwrap_or("");
1765 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1766 }
1767 "run_query" => {
1768 let query_source = args
1769 .and_then(|a| a.get("query_source"))
1770 .and_then(|v| v.as_str())
1771 .unwrap_or("");
1772 let language = args
1773 .and_then(|a| a.get("language"))
1774 .and_then(|v| v.as_str())
1775 .unwrap_or("");
1776 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1777 }
1778 _ => format!("{module_path}.{method_name}({result_var})"),
1779 }
1780}
1781
1782fn elixir_module_name(category: &str) -> String {
1784 use heck::ToUpperCamelCase;
1785 category.to_upper_camel_case()
1786}
1787
1788fn json_to_elixir(value: &serde_json::Value) -> String {
1790 match value {
1791 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1792 serde_json::Value::Bool(true) => "true".to_string(),
1793 serde_json::Value::Bool(false) => "false".to_string(),
1794 serde_json::Value::Number(n) => {
1795 let s = n.to_string().replace("e+", "e");
1799 if s.contains('e') && !s.contains('.') {
1800 s.replacen('e', ".0e", 1)
1802 } else {
1803 s
1804 }
1805 }
1806 serde_json::Value::Null => "nil".to_string(),
1807 serde_json::Value::Array(arr) => {
1808 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1809 format!("[{}]", items.join(", "))
1810 }
1811 serde_json::Value::Object(map) => {
1812 let entries: Vec<String> = map
1813 .iter()
1814 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1815 .collect();
1816 format!("%{{{}}}", entries.join(", "))
1817 }
1818 }
1819}
1820
1821fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1823 use std::fmt::Write as FmtWrite;
1824 let mut visitor_obj = String::new();
1825 let _ = writeln!(visitor_obj, "%{{");
1826 for (method_name, action) in &visitor_spec.callbacks {
1827 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1828 }
1829 let _ = writeln!(visitor_obj, " }}");
1830
1831 setup_lines.push(format!("visitor = {visitor_obj}"));
1832 "visitor".to_string()
1833}
1834
1835fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1837 use std::fmt::Write as FmtWrite;
1838
1839 let handle_method = format!("handle_{}", &method_name[6..]); let arg_binding = match action {
1849 CallbackAction::CustomTemplate { .. } => "args",
1850 _ => "_args",
1851 };
1852 let _ = writeln!(out, " :{handle_method} => fn({arg_binding}) ->");
1853 match action {
1854 CallbackAction::Skip => {
1855 let _ = writeln!(out, " :skip");
1856 }
1857 CallbackAction::Continue => {
1858 let _ = writeln!(out, " :continue");
1859 }
1860 CallbackAction::PreserveHtml => {
1861 let _ = writeln!(out, " :preserve_html");
1862 }
1863 CallbackAction::Custom { output } => {
1864 let escaped = escape_elixir(output);
1865 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1866 }
1867 CallbackAction::CustomTemplate { template } => {
1868 let expr = template_to_elixir_concat(template);
1872 let _ = writeln!(out, " {{:custom, {expr}}}");
1873 }
1874 }
1875 let _ = writeln!(out, " end,");
1876}
1877
1878fn template_to_elixir_concat(template: &str) -> String {
1883 let mut parts: Vec<String> = Vec::new();
1884 let mut static_buf = String::new();
1885 let mut chars = template.chars().peekable();
1886
1887 while let Some(ch) = chars.next() {
1888 if ch == '{' {
1889 let mut key = String::new();
1890 let mut closed = false;
1891 for kc in chars.by_ref() {
1892 if kc == '}' {
1893 closed = true;
1894 break;
1895 }
1896 key.push(kc);
1897 }
1898 if closed && !key.is_empty() {
1899 if !static_buf.is_empty() {
1900 let escaped = escape_elixir(&static_buf);
1901 parts.push(format!("\"{escaped}\""));
1902 static_buf.clear();
1903 }
1904 let escaped_key = escape_elixir(&key);
1905 parts.push(format!("Map.get(args, \"{escaped_key}\", \"\")"));
1906 } else {
1907 static_buf.push('{');
1908 static_buf.push_str(&key);
1909 if !closed {
1910 }
1912 }
1913 } else {
1914 static_buf.push(ch);
1915 }
1916 }
1917
1918 if !static_buf.is_empty() {
1919 let escaped = escape_elixir(&static_buf);
1920 parts.push(format!("\"{escaped}\""));
1921 }
1922
1923 if parts.is_empty() {
1924 return "\"\"".to_string();
1925 }
1926 parts.join(" <> ")
1927}
1928
1929fn fixture_has_elixir_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
1930 if fixture.is_http_test() {
1932 return false;
1933 }
1934 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
1935 let elixir_override = call_config
1936 .overrides
1937 .get("elixir")
1938 .or_else(|| e2e_config.call.overrides.get("elixir"));
1939 if elixir_override.and_then(|o| o.client_factory.as_deref()).is_some() {
1941 return true;
1942 }
1943 let function_from_override = elixir_override.and_then(|o| o.function.as_deref());
1948
1949 function_from_override.is_some() || !call_config.function.is_empty()
1951}