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 _enums: &[alef_core::ir::EnumDef],
31 ) -> Result<Vec<GeneratedFile>> {
32 let lang = self.language_name();
33 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35 let mut files = Vec::new();
36
37 let call = &e2e_config.call;
39 let overrides = call.overrides.get(lang);
40 let raw_module = overrides
41 .and_then(|o| o.module.as_ref())
42 .cloned()
43 .unwrap_or_else(|| call.module.clone());
44 let module_path = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase()) {
48 raw_module.clone()
49 } else {
50 elixir_module_name(&raw_module)
51 };
52 let base_function_name = overrides
53 .and_then(|o| o.function.as_ref())
54 .cloned()
55 .unwrap_or_else(|| call.function.clone());
56 let function_name =
61 if call.r#async && !base_function_name.ends_with("_async") && !base_function_name.ends_with("_stream") {
62 format!("{base_function_name}_async")
63 } else {
64 base_function_name
65 };
66 let options_type = overrides.and_then(|o| o.options_type.clone());
67 let options_default_fn = overrides.and_then(|o| o.options_via.clone());
68 let empty_enum_fields = HashMap::new();
69 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
70 let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
71 let empty_atom_fields = std::collections::HashSet::new();
72 let handle_atom_list_fields = overrides
73 .map(|o| &o.handle_atom_list_fields)
74 .unwrap_or(&empty_atom_fields);
75 let result_var = &call.result_var;
76
77 let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
79 let has_nif_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| !f.is_http_test()));
80 let has_mock_server_tests = groups.iter().any(|g| {
82 g.fixtures.iter().any(|f| {
83 if f.needs_mock_server() {
84 return true;
85 }
86 let cc = e2e_config.resolve_call_for_fixture(
87 f.call.as_deref(),
88 &f.id,
89 &f.resolved_category(),
90 &f.tags,
91 &f.input,
92 );
93 let elixir_override = cc
94 .overrides
95 .get("elixir")
96 .or_else(|| e2e_config.call.overrides.get("elixir"));
97 elixir_override.and_then(|o| o.client_factory.as_deref()).is_some()
98 })
99 });
100
101 let pkg_ref = e2e_config.resolve_package(lang);
103 let pkg_dep_ref = if has_nif_tests {
104 match e2e_config.dep_mode {
105 crate::config::DependencyMode::Local => pkg_ref
106 .as_ref()
107 .and_then(|p| p.path.as_deref())
108 .unwrap_or("../../packages/elixir")
109 .to_string(),
110 crate::config::DependencyMode::Registry => pkg_ref
111 .as_ref()
112 .and_then(|p| p.version.clone())
113 .or_else(|| config.resolved_version())
114 .unwrap_or_else(|| "0.1.0".to_string()),
115 }
116 } else {
117 String::new()
118 };
119
120 let pkg_atom = config.elixir_app_name();
129 files.push(GeneratedFile {
130 path: output_base.join("mix.exs"),
131 content: render_mix_exs(
132 &pkg_atom,
133 &pkg_dep_ref,
134 e2e_config.dep_mode,
135 has_http_tests,
136 has_nif_tests,
137 ),
138 generated_header: false,
139 });
140
141 files.push(GeneratedFile {
143 path: output_base.join("lib").join("e2e_elixir.ex"),
144 content: "defmodule E2eElixir do\n @moduledoc false\nend\n".to_string(),
145 generated_header: false,
146 });
147
148 files.push(GeneratedFile {
150 path: output_base.join("test").join("test_helper.exs"),
151 content: render_test_helper(has_http_tests || has_mock_server_tests),
152 generated_header: false,
153 });
154
155 for group in groups {
157 let active: Vec<&Fixture> = group
158 .fixtures
159 .iter()
160 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
161 .collect();
162
163 if active.is_empty() {
164 continue;
165 }
166
167 let filename = format!("{}_test.exs", sanitize_filename(&group.category));
168 let content = render_test_file(
169 &group.category,
170 &active,
171 e2e_config,
172 &module_path,
173 &function_name,
174 result_var,
175 &e2e_config.call.args,
176 options_type.as_deref(),
177 options_default_fn.as_deref(),
178 enum_fields,
179 handle_struct_type.as_deref(),
180 handle_atom_list_fields,
181 &config.adapters,
182 );
183 files.push(GeneratedFile {
184 path: output_base.join("test").join(filename),
185 content,
186 generated_header: true,
187 });
188 }
189
190 Ok(files)
191 }
192
193 fn language_name(&self) -> &'static str {
194 "elixir"
195 }
196}
197
198fn render_test_helper(has_http_tests: bool) -> String {
199 if has_http_tests {
200 r#"ExUnit.start()
201
202# Spawn mock-server binary and set MOCK_SERVER_URL for all tests.
203mock_server_bin = Path.expand("../../rust/target/release/mock-server", __DIR__)
204fixtures_dir = Path.expand("../../../fixtures", __DIR__)
205
206if File.exists?(mock_server_bin) do
207 port = Port.open({:spawn_executable, mock_server_bin}, [
208 :binary,
209 # Use a large line buffer (default 1024 truncates `MOCK_SERVERS={...}` lines for
210 # fixture sets with many host-root routes, splitting them into `:noeol` chunks
211 # that the prefix-match clauses below would never see).
212 {:line, 65_536},
213 args: [fixtures_dir]
214 ])
215 # Read startup lines: MOCK_SERVER_URL= then MOCK_SERVERS= (always emitted, possibly `{}`).
216 # The standalone mock-server prints noisy stderr lines BEFORE the stdout sentinels;
217 # selective receive ignores anything that doesn't match the two prefix patterns.
218 # Each iteration only halts after the MOCK_SERVERS= line is processed.
219 {url, _} =
220 Enum.reduce_while(1..16, {nil, port}, fn _, {url_acc, p} ->
221 receive do
222 {^p, {:data, {:eol, "MOCK_SERVER_URL=" <> u}}} ->
223 {:cont, {u, p}}
224
225 {^p, {:data, {:eol, "MOCK_SERVERS=" <> json_val}}} ->
226 System.put_env("MOCK_SERVERS", json_val)
227 case Jason.decode(json_val) do
228 {:ok, servers} ->
229 Enum.each(servers, fn {fid, furl} ->
230 System.put_env("MOCK_SERVER_#{String.upcase(fid)}", furl)
231 end)
232
233 _ ->
234 :ok
235 end
236
237 {:halt, {url_acc, p}}
238 after
239 30_000 ->
240 raise "mock-server startup timeout"
241 end
242 end)
243
244 if url != nil do
245 System.put_env("MOCK_SERVER_URL", url)
246 end
247end
248"#
249 .to_string()
250 } else {
251 "ExUnit.start()\n".to_string()
252 }
253}
254
255fn render_mix_exs(
256 pkg_name: &str,
257 pkg_path: &str,
258 dep_mode: crate::config::DependencyMode,
259 has_http_tests: bool,
260 has_nif_tests: bool,
261) -> String {
262 let mut out = String::new();
263 let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
264 let _ = writeln!(out, " use Mix.Project");
265 let _ = writeln!(out);
266 let _ = writeln!(out, " def project do");
267 let _ = writeln!(out, " [");
268 let _ = writeln!(out, " app: :e2e_elixir,");
269 let _ = writeln!(out, " version: \"0.1.0\",");
270 let _ = writeln!(out, " elixir: \"~> 1.14\",");
271 let _ = writeln!(out, " deps: deps()");
272 let _ = writeln!(out, " ]");
273 let _ = writeln!(out, " end");
274 let _ = writeln!(out);
275 let _ = writeln!(out, " defp deps do");
276 let _ = writeln!(out, " [");
277
278 let mut deps: Vec<String> = Vec::new();
280
281 if has_nif_tests && !pkg_path.is_empty() {
283 let pkg_atom = pkg_name;
284 let nif_dep = match dep_mode {
285 crate::config::DependencyMode::Local => {
286 format!(" {{:{pkg_atom}, path: \"{pkg_path}\"}}")
287 }
288 crate::config::DependencyMode::Registry => {
289 format!(" {{:{pkg_atom}, \"{pkg_path}\"}}")
291 }
292 };
293 deps.push(nif_dep);
294 deps.push(format!(
296 " {{:rustler_precompiled, \"{rp}\"}}",
297 rp = tv::hex::RUSTLER_PRECOMPILED
298 ));
299 deps.push(format!(
304 " {{:rustler, \"{rustler}\", runtime: false}}",
305 rustler = tv::hex::RUSTLER
306 ));
307 }
308
309 if has_http_tests {
311 deps.push(format!(" {{:req, \"{req}\"}}", req = tv::hex::REQ));
312 deps.push(format!(" {{:jason, \"{jason}\"}}", jason = tv::hex::JASON));
313 }
314
315 let _ = writeln!(out, "{}", deps.join(",\n"));
316 let _ = writeln!(out, " ]");
317 let _ = writeln!(out, " end");
318 let _ = writeln!(out, "end");
319 out
320}
321
322#[allow(clippy::too_many_arguments)]
323fn render_test_file(
324 category: &str,
325 fixtures: &[&Fixture],
326 e2e_config: &E2eConfig,
327 module_path: &str,
328 function_name: &str,
329 result_var: &str,
330 args: &[crate::config::ArgMapping],
331 options_type: Option<&str>,
332 options_default_fn: Option<&str>,
333 enum_fields: &HashMap<String, String>,
334 handle_struct_type: Option<&str>,
335 handle_atom_list_fields: &std::collections::HashSet<String>,
336 adapters: &[alef_core::config::extras::AdapterConfig],
337) -> String {
338 let mut out = String::new();
339 out.push_str(&hash::header(CommentStyle::Hash));
340 let _ = writeln!(out, "# E2e tests for category: {category}");
341 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
342
343 let has_http = fixtures.iter().any(|f| f.is_http_test());
345
346 let async_flag = if has_http { "true" } else { "false" };
349 let _ = writeln!(out, " use ExUnit.Case, async: {async_flag}");
350
351 if has_http {
352 let _ = writeln!(out);
353 let _ = writeln!(out, " defp mock_server_url do");
354 let _ = writeln!(
355 out,
356 " System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
357 );
358 let _ = writeln!(out, " end");
359 }
360
361 let has_array_contains = fixtures.iter().any(|fixture| {
364 let cc = e2e_config.resolve_call_for_fixture(
365 fixture.call.as_deref(),
366 &fixture.id,
367 &fixture.resolved_category(),
368 &fixture.tags,
369 &fixture.input,
370 );
371 let fr = FieldResolver::new(
372 e2e_config.effective_fields(cc),
373 e2e_config.effective_fields_optional(cc),
374 e2e_config.effective_result_fields(cc),
375 e2e_config.effective_fields_array(cc),
376 &std::collections::HashSet::new(),
377 );
378 fixture.assertions.iter().any(|a| {
379 matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
380 && a.field
381 .as_deref()
382 .is_some_and(|f| !f.is_empty() && fr.is_array(fr.resolve(f)))
383 })
384 });
385 if has_array_contains {
386 let _ = writeln!(out);
387 let _ = writeln!(out, " defp alef_e2e_item_texts(item) when is_binary(item), do: [item]");
388 let _ = writeln!(out, " defp alef_e2e_item_texts(item) do");
389 let _ = writeln!(out, " [:kind, :name, :signature, :path, :alias, :text, :source]");
390 let _ = writeln!(out, " |> Enum.filter(&Map.has_key?(item, &1))");
391 let _ = writeln!(out, " |> Enum.flat_map(fn attr ->");
392 let _ = writeln!(out, " case Map.get(item, attr) do");
393 let _ = writeln!(out, " nil -> []");
394 let _ = writeln!(
395 out,
396 " atom when is_atom(atom) -> [atom |> to_string() |> String.capitalize()]"
397 );
398 let _ = writeln!(out, " str -> [inspect(str)]");
399 let _ = writeln!(out, " end");
400 let _ = writeln!(out, " end)");
401 let _ = writeln!(out, " end");
402 }
403
404 let has_format_metadata = fixtures.iter().any(|fixture| {
407 fixture.assertions.iter().any(|a| {
408 a.field
409 .as_deref()
410 .is_some_and(|f| f.contains("format") && f.contains("metadata"))
411 })
412 });
413 if has_format_metadata {
414 let _ = writeln!(out);
415 let _ = writeln!(
416 out,
417 " defp alef_e2e_format_to_string(value) when is_binary(value), do: value"
418 );
419 let _ = writeln!(out, " defp alef_e2e_format_to_string(metadata) do");
420 let _ = writeln!(out, " case metadata.image do");
421 let _ = writeln!(out, " %{{format: fmt}} when is_binary(fmt) -> fmt");
422 let _ = writeln!(out, " _ ->");
423 let _ = writeln!(out, " case metadata.pdf do");
424 let _ = writeln!(out, " %{{}} -> \"PDF\"");
425 let _ = writeln!(out, " _ ->");
426 let _ = writeln!(out, " case metadata.html do");
427 let _ = writeln!(out, " %{{}} -> \"HTML\"");
428 let _ = writeln!(out, " _ -> inspect(metadata)");
429 let _ = writeln!(out, " end");
430 let _ = writeln!(out, " end");
431 let _ = writeln!(out, " end");
432 let _ = writeln!(out, " end");
433 }
434
435 let _ = writeln!(out);
436
437 for (i, fixture) in fixtures.iter().enumerate() {
438 if let Some(http) = &fixture.http {
439 render_http_test_case(&mut out, fixture, http);
440 } else {
441 render_test_case(
442 &mut out,
443 fixture,
444 e2e_config,
445 module_path,
446 function_name,
447 result_var,
448 args,
449 options_type,
450 options_default_fn,
451 enum_fields,
452 handle_struct_type,
453 handle_atom_list_fields,
454 adapters,
455 );
456 }
457 if i + 1 < fixtures.len() {
458 let _ = writeln!(out);
459 }
460 }
461
462 let _ = writeln!(out, "end");
463 out
464}
465
466const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
473
474const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
477
478struct ElixirTestClientRenderer<'a> {
482 fixture_id: &'a str,
485 expected_status: u16,
487}
488
489impl<'a> client::TestClientRenderer for ElixirTestClientRenderer<'a> {
490 fn language_name(&self) -> &'static str {
491 "elixir"
492 }
493
494 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
500 let escaped_description = description.replace('"', "\\\"");
501 let _ = writeln!(out, " describe \"{fn_name}\" do");
502 if skip_reason.is_some() {
503 let _ = writeln!(out, " @tag :skip");
504 }
505 let _ = writeln!(out, " test \"{escaped_description}\" do");
506 }
507
508 fn render_test_close(&self, out: &mut String) {
510 let _ = writeln!(out, " end");
511 let _ = writeln!(out, " end");
512 }
513
514 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
516 let method = ctx.method.to_lowercase();
517 let mut opts: Vec<String> = Vec::new();
518
519 if let Some(body) = ctx.body {
520 let elixir_val = json_to_elixir(body);
521 opts.push(format!("json: {elixir_val}"));
522 }
523
524 if !ctx.headers.is_empty() {
525 let header_pairs: Vec<String> = ctx
526 .headers
527 .iter()
528 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
529 .collect();
530 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
531 }
532
533 if !ctx.cookies.is_empty() {
534 let cookie_str = ctx
535 .cookies
536 .iter()
537 .map(|(k, v)| format!("{k}={v}"))
538 .collect::<Vec<_>>()
539 .join("; ");
540 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
541 }
542
543 if !ctx.query_params.is_empty() {
544 let pairs: Vec<String> = ctx
545 .query_params
546 .iter()
547 .map(|(k, v)| {
548 let val_str = match v {
549 serde_json::Value::String(s) => s.clone(),
550 other => other.to_string(),
551 };
552 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
553 })
554 .collect();
555 opts.push(format!("params: [{}]", pairs.join(", ")));
556 }
557
558 if (300..400).contains(&self.expected_status) {
561 opts.push("redirect: false".to_string());
562 }
563
564 let fixture_id = escape_elixir(self.fixture_id);
565 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{fixture_id}\"");
566
567 if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
568 if opts.is_empty() {
569 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
570 } else {
571 let opts_str = opts.join(", ");
572 let _ = writeln!(
573 out,
574 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
575 );
576 }
577 } else {
578 opts.insert(0, format!("method: :{method}"));
579 opts.insert(1, format!("url: {url_expr}"));
580 let opts_str = opts.join(", ");
581 let _ = writeln!(out, " {{:ok, response}} = Req.request({opts_str})");
582 }
583 }
584
585 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
586 let _ = writeln!(out, " assert {response_var}.status == {status}");
587 }
588
589 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
594 let header_key = name.to_lowercase();
595 if header_key == "connection" {
597 return;
598 }
599 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
600 let get_header_expr = format!(
601 "Enum.find_value({response_var}.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
602 );
603 match expected {
604 "<<present>>" => {
605 let _ = writeln!(out, " assert {get_header_expr} != nil");
606 }
607 "<<absent>>" => {
608 let _ = writeln!(out, " assert {get_header_expr} == nil");
609 }
610 "<<uuid>>" => {
611 let var = sanitize_ident(&header_key);
612 let _ = writeln!(out, " header_val_{var} = {get_header_expr}");
613 let _ = writeln!(
614 out,
615 " 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}))"
616 );
617 }
618 literal => {
619 let val_lit = format!("\"{}\"", escape_elixir(literal));
620 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
621 }
622 }
623 }
624
625 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
630 let elixir_val = json_to_elixir(expected);
631 match expected {
632 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
633 let _ = writeln!(
634 out,
635 " body_decoded = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
636 );
637 let _ = writeln!(out, " assert body_decoded == {elixir_val}");
638 }
639 _ => {
640 let _ = writeln!(out, " assert {response_var}.body == {elixir_val}");
641 }
642 }
643 }
644
645 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
647 if let Some(obj) = expected.as_object() {
648 let _ = writeln!(
649 out,
650 " decoded_body = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
651 );
652 for (key, val) in obj {
653 let key_lit = format!("\"{}\"", escape_elixir(key));
654 let elixir_val = json_to_elixir(val);
655 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
656 }
657 }
658 }
659
660 fn render_assert_validation_errors(
663 &self,
664 out: &mut String,
665 response_var: &str,
666 errors: &[ValidationErrorExpectation],
667 ) {
668 for err in errors {
669 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
670 let _ = writeln!(
671 out,
672 " assert String.contains?(Jason.encode!({response_var}.body), {msg_lit})"
673 );
674 }
675 }
676}
677
678fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
684 let method = http.request.method.to_uppercase();
685
686 if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
690 let test_name = sanitize_ident(&fixture.id);
691 let test_label = fixture.id.replace('"', "\\\"");
692 let path = &http.request.path;
693 let _ = writeln!(out, " describe \"{test_name}\" do");
694 let _ = writeln!(out, " @tag :skip");
695 let _ = writeln!(out, " test \"{method} {path} - {test_label}\" do");
696 let _ = writeln!(out, " end");
697 let _ = writeln!(out, " end");
698 return;
699 }
700
701 let renderer = ElixirTestClientRenderer {
702 fixture_id: &fixture.id,
703 expected_status: http.expected_response.status_code,
704 };
705 client::http_call::render_http_test(out, &renderer, fixture);
706}
707
708#[allow(clippy::too_many_arguments)]
713fn render_test_case(
714 out: &mut String,
715 fixture: &Fixture,
716 e2e_config: &E2eConfig,
717 default_module_path: &str,
718 default_function_name: &str,
719 default_result_var: &str,
720 args: &[crate::config::ArgMapping],
721 options_type: Option<&str>,
722 options_default_fn: Option<&str>,
723 enum_fields: &HashMap<String, String>,
724 handle_struct_type: Option<&str>,
725 handle_atom_list_fields: &std::collections::HashSet<String>,
726 adapters: &[alef_core::config::extras::AdapterConfig],
727) {
728 let test_name = sanitize_ident(&fixture.id);
729 let test_label = fixture.id.replace('"', "\\\"");
730
731 if fixture.mock_response.is_none() && !fixture_has_elixir_callable(fixture, e2e_config) {
737 let _ = writeln!(out, " describe \"{test_name}\" do");
738 let _ = writeln!(out, " @tag :skip");
739 let _ = writeln!(out, " test \"{test_label}\" do");
740 let _ = writeln!(
741 out,
742 " # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
743 );
744 let _ = writeln!(out, " :ok");
745 let _ = writeln!(out, " end");
746 let _ = writeln!(out, " end");
747 return;
748 }
749
750 let call_config = e2e_config.resolve_call_for_fixture(
752 fixture.call.as_deref(),
753 &fixture.id,
754 &fixture.resolved_category(),
755 &fixture.tags,
756 &fixture.input,
757 );
758 let call_field_resolver = FieldResolver::new(
760 e2e_config.effective_fields(call_config),
761 e2e_config.effective_fields_optional(call_config),
762 e2e_config.effective_result_fields(call_config),
763 e2e_config.effective_fields_array(call_config),
764 &std::collections::HashSet::new(),
765 );
766 let field_resolver = &call_field_resolver;
767 let lang = "elixir";
768 let call_overrides = call_config.overrides.get(lang);
769
770 let base_fn = call_overrides
773 .and_then(|o| o.function.as_ref())
774 .cloned()
775 .unwrap_or_else(|| call_config.function.clone());
776 if base_fn.starts_with("batch_extract_") {
777 let _ = writeln!(
778 out,
779 " describe \"{test_name}\" do",
780 test_name = sanitize_ident(&fixture.id)
781 );
782 let _ = writeln!(out, " @tag :skip");
783 let _ = writeln!(
784 out,
785 " test \"{test_label}\" do",
786 test_label = fixture.id.replace('"', "\\\"")
787 );
788 let _ = writeln!(
789 out,
790 " # batch functions excluded from Elixir binding: unsafe NIF tuple marshalling"
791 );
792 let _ = writeln!(out, " :ok");
793 let _ = writeln!(out, " end");
794 let _ = writeln!(out, " end");
795 return;
796 }
797
798 let (module_path, function_name, result_var) = if fixture.call.is_some() {
801 let raw_module = call_overrides
802 .and_then(|o| o.module.as_ref())
803 .cloned()
804 .unwrap_or_else(|| call_config.module.clone());
805 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
806 {
807 raw_module.clone()
808 } else {
809 elixir_module_name(&raw_module)
810 };
811 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") && !base_fn.ends_with("_stream") {
812 format!("{base_fn}_async")
813 } else {
814 base_fn
815 };
816 (resolved_module, resolved_fn, call_config.result_var.clone())
817 } else {
818 (
819 default_module_path.to_string(),
820 default_function_name.to_string(),
821 default_result_var.to_string(),
822 )
823 };
824
825 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
826 let validation_creation_failure = expects_error && fixture.resolved_category() == "validation";
830
831 let (
833 effective_args,
834 effective_options_type,
835 effective_options_default_fn,
836 effective_enum_fields,
837 effective_handle_struct_type,
838 effective_handle_atom_list_fields,
839 );
840 let empty_enum_fields_local: HashMap<String, String>;
841 let empty_atom_fields_local: std::collections::HashSet<String>;
842 let (
843 resolved_args,
844 resolved_options_type,
845 resolved_options_default_fn,
846 resolved_enum_fields_ref,
847 resolved_handle_struct_type,
848 resolved_handle_atom_list_fields_ref,
849 ) = if fixture.call.is_some() {
850 let co = call_config.overrides.get(lang);
851 effective_args = call_config.args.as_slice();
852 effective_options_type = co.and_then(|o| o.options_type.as_deref());
853 effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
854 empty_enum_fields_local = HashMap::new();
855 effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
856 effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
857 empty_atom_fields_local = std::collections::HashSet::new();
858 effective_handle_atom_list_fields = co
859 .map(|o| &o.handle_atom_list_fields)
860 .unwrap_or(&empty_atom_fields_local);
861 (
862 effective_args,
863 effective_options_type,
864 effective_options_default_fn,
865 effective_enum_fields,
866 effective_handle_struct_type,
867 effective_handle_atom_list_fields,
868 )
869 } else {
870 (
871 args as &[_],
872 options_type,
873 options_default_fn,
874 enum_fields,
875 handle_struct_type,
876 handle_atom_list_fields,
877 )
878 };
879
880 let test_documents_path = e2e_config.test_documents_relative_from(0);
881 let adapter_request_type: Option<String> = adapters
882 .iter()
883 .find(|a| a.name == call_config.function.as_str())
884 .and_then(|a| a.request_type.as_deref())
885 .map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());
886 let (mut setup_lines, args_str) = build_args_and_setup(
887 &fixture.input,
888 resolved_args,
889 &module_path,
890 resolved_options_type,
891 resolved_options_default_fn,
892 resolved_enum_fields_ref,
893 fixture,
894 resolved_handle_struct_type,
895 resolved_handle_atom_list_fields_ref,
896 &test_documents_path,
897 adapter_request_type.as_deref(),
898 );
899
900 let visitor_var = fixture
902 .visitor
903 .as_ref()
904 .map(|visitor_spec| build_elixir_visitor(&mut setup_lines, visitor_spec));
905
906 let final_args = if let Some(ref visitor_var) = visitor_var {
909 let parts: Vec<&str> = args_str.split(", ").collect();
913 if parts.len() == 2 && parts[1] == "nil" {
914 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
916 } else if parts.len() == 2 {
917 setup_lines.push(format!(
919 "{} = Map.put({}, :visitor, {})",
920 parts[1], parts[1], visitor_var
921 ));
922 args_str
923 } else if parts.len() == 1 {
924 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
926 } else {
927 args_str
928 }
929 } else {
930 args_str
931 };
932
933 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
935 e2e_config
936 .call
937 .overrides
938 .get("elixir")
939 .and_then(|o| o.client_factory.as_deref())
940 });
941
942 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
946 let final_args_with_extras = if extra_args.is_empty() {
947 final_args
948 } else if final_args.is_empty() {
949 extra_args.join(", ")
950 } else {
951 format!("{final_args}, {}", extra_args.join(", "))
952 };
953
954 let effective_args = if client_factory.is_some() {
956 if final_args_with_extras.is_empty() {
957 "client".to_string()
958 } else {
959 format!("client, {final_args_with_extras}")
960 }
961 } else {
962 final_args_with_extras
963 };
964
965 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
969 let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
970 let needs_api_key_skip = !has_mock && api_key_var_opt.is_some();
971 let needs_env_fallback = has_mock && api_key_var_opt.is_some();
974
975 let _ = writeln!(out, " describe \"{test_name}\" do");
976 let _ = writeln!(out, " test \"{test_label}\" do");
977
978 if needs_api_key_skip {
979 let api_key_var = api_key_var_opt.unwrap_or("");
980 let _ = writeln!(out, " if System.get_env(\"{api_key_var}\") in [nil, \"\"] do");
981 let _ = writeln!(out, " # {api_key_var} not set — skipping live smoke test");
982 let _ = writeln!(out, " :ok");
983 let _ = writeln!(out, " else");
984 }
985
986 if validation_creation_failure {
990 let mut emitted_error_assertion = false;
991 for line in &setup_lines {
992 if !emitted_error_assertion && line.starts_with("{:ok,") {
993 if let Some(rhs) = line.split_once('=').map(|x| x.1) {
994 let rhs = rhs.trim();
995 let _ = writeln!(out, " assert {{:error, _}} = {rhs}");
996 emitted_error_assertion = true;
997 } else {
998 let _ = writeln!(out, " {line}");
999 }
1000 } else {
1001 let _ = writeln!(out, " {line}");
1002 }
1003 }
1004 if !emitted_error_assertion {
1005 let _ = writeln!(
1006 out,
1007 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
1008 );
1009 }
1010 if needs_api_key_skip {
1011 let _ = writeln!(out, " end");
1012 }
1013 let _ = writeln!(out, " end");
1014 let _ = writeln!(out, " end");
1015 return;
1016 }
1017
1018 if expects_error {
1025 for line in &setup_lines {
1026 let _ = writeln!(out, " {line}");
1027 }
1028 if let Some(factory) = client_factory {
1029 let fixture_id = &fixture.id;
1030 let base_url_expr = if fixture.has_host_root_route() {
1031 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1032 format!(
1033 "(System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
1034 )
1035 } else {
1036 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
1037 };
1038 let _ = writeln!(
1039 out,
1040 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", base_url: {base_url_expr})"
1041 );
1042 }
1043 let _ = writeln!(
1044 out,
1045 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
1046 );
1047 if needs_api_key_skip {
1048 let _ = writeln!(out, " end");
1049 }
1050 let _ = writeln!(out, " end");
1051 let _ = writeln!(out, " end");
1052 return;
1053 }
1054
1055 for line in &setup_lines {
1056 let _ = writeln!(out, " {line}");
1057 }
1058
1059 if let Some(factory) = client_factory {
1061 let fixture_id = &fixture.id;
1062 if needs_env_fallback {
1063 let api_key_var = api_key_var_opt.unwrap_or("");
1066 let mock_url_expr = if fixture.has_host_root_route() {
1067 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1068 format!(
1069 "System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\""
1070 )
1071 } else {
1072 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
1073 };
1074 let _ = writeln!(out, " api_key_val = System.get_env(\"{api_key_var}\")");
1075 let _ = writeln!(
1076 out,
1077 " {{api_key_val, client_opts}} = if api_key_val && api_key_val != \"\" do"
1078 );
1079 let _ = writeln!(
1080 out,
1081 " IO.puts(\"{fixture_id}: using real API ({api_key_var} is set)\")"
1082 );
1083 let _ = writeln!(out, " {{api_key_val, []}}");
1084 let _ = writeln!(out, " else");
1085 let _ = writeln!(
1086 out,
1087 " IO.puts(\"{fixture_id}: using mock server ({api_key_var} not set)\")"
1088 );
1089 let _ = writeln!(out, " {{\"test-key\", [base_url: {mock_url_expr}]}}");
1090 let _ = writeln!(out, " end");
1091 let _ = writeln!(
1092 out,
1093 " {{:ok, client}} = {module_path}.{factory}(api_key_val, client_opts)"
1094 );
1095 } else {
1096 let base_url_expr = if fixture.has_host_root_route() {
1097 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1098 format!(
1099 "(System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
1100 )
1101 } else {
1102 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
1103 };
1104 let _ = writeln!(
1105 out,
1106 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", base_url: {base_url_expr})"
1107 );
1108 }
1109 }
1110
1111 let returns_result = call_overrides
1113 .and_then(|o| o.returns_result)
1114 .unwrap_or(call_config.returns_result || client_factory.is_some());
1115
1116 let result_is_simple = call_config.result_is_simple || call_overrides.is_some_and(|o| o.result_is_simple);
1121
1122 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1124 let chunks_var = "chunks";
1126
1127 let actual_result_var = if fixture.assertions.is_empty() && !is_streaming {
1130 format!("_{result_var}")
1131 } else {
1132 result_var.to_string()
1133 };
1134
1135 if returns_result {
1136 let _ = writeln!(
1137 out,
1138 " {{:ok, {actual_result_var}}} = {module_path}.{function_name}({effective_args})"
1139 );
1140 } else {
1141 let _ = writeln!(
1143 out,
1144 " {actual_result_var} = {module_path}.{function_name}({effective_args})"
1145 );
1146 }
1147
1148 if is_streaming {
1150 if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
1151 "elixir",
1152 &result_var,
1153 chunks_var,
1154 ) {
1155 let _ = writeln!(out, " {collect}");
1156 }
1157 }
1158
1159 for assertion in &fixture.assertions {
1160 render_assertion(
1161 out,
1162 assertion,
1163 if is_streaming { chunks_var } else { &result_var },
1164 field_resolver,
1165 &module_path,
1166 e2e_config.effective_fields_enum(call_config),
1167 resolved_enum_fields_ref,
1168 result_is_simple,
1169 is_streaming,
1170 );
1171 }
1172
1173 if needs_api_key_skip {
1174 let _ = writeln!(out, " end");
1175 }
1176 let _ = writeln!(out, " end");
1177 let _ = writeln!(out, " end");
1178}
1179
1180#[allow(clippy::too_many_arguments)]
1184fn emit_elixir_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1186 if let Some(items) = arr.as_array() {
1187 let item_strs: Vec<String> = items
1188 .iter()
1189 .filter_map(|item| {
1190 if let Some(obj) = item.as_object() {
1191 match elem_type {
1192 "BatchBytesItem" => {
1193 let content = obj.get("content").and_then(|v| v.as_array());
1194 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1195 let content_code = if let Some(arr) = content {
1196 let bytes: Vec<String> =
1197 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1198 format!("<<{}>>", bytes.join(", "))
1199 } else {
1200 "<<>>".to_string()
1201 };
1202 Some(format!(
1203 "%BatchBytesItem{{content: {}, mime_type: \"{}\"}}",
1204 content_code, mime_type
1205 ))
1206 }
1207 "BatchFileItem" => {
1208 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1209 Some(format!("%BatchFileItem{{path: \"{}\"}}", path))
1210 }
1211 _ => None,
1212 }
1213 } else {
1214 None
1215 }
1216 })
1217 .collect();
1218 format!("[{}]", item_strs.join(", "))
1219 } else {
1220 "[]".to_string()
1221 }
1222}
1223
1224#[allow(clippy::too_many_arguments)]
1225fn build_args_and_setup(
1226 input: &serde_json::Value,
1227 args: &[crate::config::ArgMapping],
1228 module_path: &str,
1229 options_type: Option<&str>,
1230 options_default_fn: Option<&str>,
1231 enum_fields: &HashMap<String, String>,
1232 fixture: &crate::fixture::Fixture,
1233 _handle_struct_type: Option<&str>,
1234 _handle_atom_list_fields: &std::collections::HashSet<String>,
1235 test_documents_path: &str,
1236 adapter_request_type: Option<&str>,
1237) -> (Vec<String>, String) {
1238 let fixture_id = &fixture.id;
1239 if args.is_empty() {
1240 let cleaned_input = match input {
1245 serde_json::Value::Object(m) => {
1246 let mut cleaned = m.clone();
1247 cleaned.remove("setup");
1248 if cleaned.is_empty() {
1249 serde_json::Value::Null
1250 } else {
1251 serde_json::Value::Object(cleaned)
1252 }
1253 }
1254 other => other.clone(),
1255 };
1256 let is_empty_input = matches!(cleaned_input, serde_json::Value::Null);
1257 if is_empty_input {
1258 return (Vec::new(), String::new());
1259 }
1260 return (Vec::new(), json_to_elixir(&cleaned_input));
1261 }
1262
1263 let mut setup_lines: Vec<String> = Vec::new();
1264 let mut parts: Vec<String> = Vec::new();
1265
1266 for arg in args {
1267 if arg.arg_type == "mock_url" {
1268 if fixture.has_host_root_route() {
1269 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1270 setup_lines.push(format!(
1271 "{} = System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1272 arg.name,
1273 ));
1274 } else {
1275 setup_lines.push(format!(
1276 "{} = (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1277 arg.name,
1278 ));
1279 }
1280 if let Some(req_type) = adapter_request_type {
1281 let req_var = format!("{}_req", arg.name);
1282 setup_lines.push(format!("{req_var} = %{module_path}.{req_type}{{url: {}}}", arg.name,));
1283 parts.push(req_var);
1284 } else {
1285 parts.push(arg.name.clone());
1286 }
1287 continue;
1288 }
1289
1290 if arg.arg_type == "mock_url_list" {
1291 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1298 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1299 let val = input.get(field).unwrap_or(&serde_json::Value::Null);
1300 let paths: Vec<String> = if let Some(arr) = val.as_array() {
1301 arr.iter()
1302 .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", escape_elixir(s))))
1303 .collect()
1304 } else {
1305 Vec::new()
1306 };
1307 let paths_literal = paths.join(", ");
1308 let name = &arg.name;
1309 setup_lines.push(format!(
1310 "{name}_base = System.get_env(\"{env_key}\") || ((System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
1311 ));
1312 setup_lines.push(format!(
1313 "{name} = Enum.map([{paths_literal}], fn p -> if String.starts_with?(p, \"http\"), do: p, else: {name}_base <> p end)"
1314 ));
1315 parts.push(name.clone());
1316 continue;
1317 }
1318
1319 if arg.arg_type == "handle" {
1320 let constructor_name = format!("create_{}", arg.name.to_snake_case());
1324 let config_value = if arg.field == "input" {
1325 input
1326 } else {
1327 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1328 input.get(field).unwrap_or(&serde_json::Value::Null)
1329 };
1330 let name = &arg.name;
1331 if config_value.is_null()
1332 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1333 {
1334 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
1335 } else {
1336 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
1339 let escaped = escape_elixir(&json_str);
1340 setup_lines.push(format!("{name}_config = \"{escaped}\""));
1341 setup_lines.push(format!(
1342 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
1343 ));
1344 }
1345 parts.push(arg.name.clone());
1346 continue;
1347 }
1348
1349 let val = if arg.field == "input" {
1350 Some(input)
1351 } else {
1352 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1353 input.get(field)
1354 };
1355 match val {
1356 None | Some(serde_json::Value::Null) if arg.optional => {
1357 continue;
1360 }
1361 None | Some(serde_json::Value::Null) => {
1362 let default_val = match arg.arg_type.as_str() {
1364 "string" => "\"\"".to_string(),
1365 "int" | "integer" => "0".to_string(),
1366 "float" | "number" => "0.0".to_string(),
1367 "bool" | "boolean" => "false".to_string(),
1368 _ => "nil".to_string(),
1369 };
1370 parts.push(default_val);
1371 }
1372 Some(v) => {
1373 if arg.arg_type == "file_path" {
1376 if let Some(path_str) = v.as_str() {
1377 let full_path = format!("{test_documents_path}/{path_str}");
1378 let formatted = format!("\"{}\"", escape_elixir(&full_path));
1379 if arg.optional {
1380 parts.push(format!("{}: {formatted}", arg.name));
1381 } else {
1382 parts.push(formatted);
1383 }
1384 continue;
1385 }
1386 }
1387 if arg.arg_type == "bytes" {
1390 if let Some(raw) = v.as_str() {
1391 let var_name = &arg.name;
1392 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
1393 let formatted = format!("\"{}\"", escape_elixir(raw));
1395 if arg.optional {
1396 parts.push(format!("{}: {formatted}", arg.name));
1397 } else {
1398 parts.push(formatted);
1399 }
1400 } else {
1401 let first = raw.chars().next().unwrap_or('\0');
1402 let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
1403 && raw
1404 .find('/')
1405 .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
1406 if is_file_path {
1407 let full_path = format!("{test_documents_path}/{raw}");
1410 let escaped = escape_elixir(&full_path);
1411 setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
1412 if arg.optional {
1413 parts.push(format!("{}: {var_name}", arg.name));
1414 } else {
1415 parts.push(var_name.to_string());
1416 }
1417 } else {
1418 setup_lines.push(format!(
1420 "{var_name} = Base.decode64!(\"{}\", padding: false)",
1421 escape_elixir(raw)
1422 ));
1423 if arg.optional {
1424 parts.push(format!("{}: {var_name}", arg.name));
1425 } else {
1426 parts.push(var_name.to_string());
1427 }
1428 }
1429 }
1430 continue;
1431 }
1432 }
1433 if arg.arg_type == "json_object" && !v.is_null() {
1435 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
1436 (options_type, options_default_fn, v.as_object())
1437 {
1438 let options_var = "options";
1440 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
1441
1442 for (k, vv) in obj.iter() {
1444 let snake_key = k.to_snake_case();
1445 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1446 if let Some(s) = vv.as_str() {
1447 let snake_val = s.to_snake_case();
1448 format!(":{snake_val}")
1450 } else {
1451 json_to_elixir(vv)
1452 }
1453 } else {
1454 json_to_elixir(vv)
1455 };
1456 setup_lines.push(format!(
1457 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
1458 ));
1459 }
1460
1461 parts.push(format!("{}: {options_var}", arg.name));
1465 continue;
1466 }
1467 if let (Some(opts_type), None, Some(obj)) = (options_type, options_default_fn, v.as_object()) {
1469 let options_var = "options";
1470 let mut field_strs = Vec::new();
1471 for (k, vv) in obj.iter() {
1472 let snake_key = k.to_snake_case();
1473 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1474 if let Some(s) = vv.as_str() {
1475 let snake_val = s.to_snake_case();
1476 format!(":{snake_val}")
1477 } else {
1478 json_to_elixir(vv)
1479 }
1480 } else {
1481 json_to_elixir(vv)
1482 };
1483 field_strs.push(format!("{snake_key}: {elixir_val}"));
1484 }
1485 let fields = field_strs.join(", ");
1486 setup_lines.push(format!("{options_var} = %{module_path}.{opts_type}{{{fields}}}"));
1487 parts.push(format!("{}: {options_var}", arg.name));
1489 continue;
1490 }
1491 if let Some(elem_type) = &arg.element_type {
1493 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1494 let formatted = emit_elixir_batch_item_array(v, elem_type);
1495 if arg.optional {
1496 parts.push(format!("{}: {formatted}", arg.name));
1497 } else {
1498 parts.push(formatted);
1499 }
1500 continue;
1501 }
1502 if v.is_array() {
1505 let formatted = json_to_elixir(v);
1506 if arg.optional {
1507 parts.push(format!("{}: {formatted}", arg.name));
1508 } else {
1509 parts.push(formatted);
1510 }
1511 continue;
1512 }
1513 }
1514 if !v.is_null() {
1518 let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
1519 let escaped = escape_elixir(&json_str);
1520 let formatted = format!("\"{escaped}\"");
1521 if arg.optional {
1522 parts.push(format!("{}: {formatted}", arg.name));
1523 } else {
1524 parts.push(formatted);
1525 }
1526 continue;
1527 }
1528 }
1529 let elixir_val = json_to_elixir(v);
1531 if arg.optional {
1532 parts.push(format!("{}: {elixir_val}", arg.name));
1533 } else {
1534 parts.push(elixir_val);
1535 }
1536 }
1537 }
1538 }
1539
1540 let mut positional_args = Vec::new();
1543 let mut keyword_args = Vec::new();
1544 let mut seen_keyword = false;
1545
1546 for (idx, part) in parts.into_iter().enumerate() {
1547 let is_keyword = part.contains(": ") && !part.starts_with('"');
1548 if is_keyword {
1549 seen_keyword = true;
1550 keyword_args.push((idx, part));
1551 } else if seen_keyword {
1552 keyword_args.push((idx, part));
1557 } else {
1558 positional_args.push(part);
1559 }
1560 }
1561
1562 let mut final_args = positional_args;
1563 final_args.extend(keyword_args.into_iter().map(|(_, arg)| arg));
1564
1565 (setup_lines, final_args.join(", "))
1566}
1567
1568fn is_numeric_expr(field_expr: &str) -> bool {
1571 field_expr.starts_with("length(")
1572}
1573
1574#[allow(clippy::too_many_arguments)]
1575fn render_assertion(
1576 out: &mut String,
1577 assertion: &Assertion,
1578 result_var: &str,
1579 field_resolver: &FieldResolver,
1580 module_path: &str,
1581 fields_enum: &std::collections::HashSet<String>,
1582 per_call_enum_fields: &HashMap<String, String>,
1583 result_is_simple: bool,
1584 is_streaming: bool,
1585) {
1586 if let Some(f) = &assertion.field {
1589 match f.as_str() {
1590 "chunks_have_content" => {
1591 let pred =
1592 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
1593 match assertion.assertion_type.as_str() {
1594 "is_true" => {
1595 let _ = writeln!(out, " assert {pred}");
1596 }
1597 "is_false" => {
1598 let _ = writeln!(out, " refute {pred}");
1599 }
1600 _ => {
1601 let _ = writeln!(
1602 out,
1603 " # skipped: unsupported assertion type on synthetic field '{f}'"
1604 );
1605 }
1606 }
1607 return;
1608 }
1609 "chunks_have_embeddings" => {
1610 let pred = format!(
1611 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
1612 );
1613 match assertion.assertion_type.as_str() {
1614 "is_true" => {
1615 let _ = writeln!(out, " assert {pred}");
1616 }
1617 "is_false" => {
1618 let _ = writeln!(out, " refute {pred}");
1619 }
1620 _ => {
1621 let _ = writeln!(
1622 out,
1623 " # skipped: unsupported assertion type on synthetic field '{f}'"
1624 );
1625 }
1626 }
1627 return;
1628 }
1629 "chunks_have_heading_context" => {
1630 let pred = format!(
1631 "Enum.all?({result_var}.chunks || [], fn c -> c.metadata != nil and c.metadata.heading_context != nil end)"
1632 );
1633 match assertion.assertion_type.as_str() {
1634 "is_true" => {
1635 let _ = writeln!(out, " assert {pred}");
1636 }
1637 "is_false" => {
1638 let _ = writeln!(out, " refute {pred}");
1639 }
1640 _ => {
1641 let _ = writeln!(
1642 out,
1643 " # skipped: unsupported assertion type on synthetic field '{f}'"
1644 );
1645 }
1646 }
1647 return;
1648 }
1649 "first_chunk_starts_with_heading" => {
1650 let expr = format!(
1651 "case List.first({result_var}.chunks || []) do
1652 c when is_map(c) -> String.trim_leading(c.content || \"\") |> String.starts_with?(\"#\")
1653 _ -> false
1654 end"
1655 );
1656 match assertion.assertion_type.as_str() {
1657 "is_true" => {
1658 let _ = writeln!(out, " assert ({expr})");
1659 }
1660 "is_false" => {
1661 let _ = writeln!(out, " refute ({expr})");
1662 }
1663 _ => {
1664 let _ = writeln!(
1665 out,
1666 " # skipped: unsupported assertion type on synthetic field '{f}'"
1667 );
1668 }
1669 }
1670 return;
1671 }
1672 "embeddings" => {
1676 match assertion.assertion_type.as_str() {
1677 "count_equals" => {
1678 if let Some(val) = &assertion.value {
1679 let ex_val = json_to_elixir(val);
1680 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
1681 }
1682 }
1683 "count_min" => {
1684 if let Some(val) = &assertion.value {
1685 let ex_val = json_to_elixir(val);
1686 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
1687 }
1688 }
1689 "not_empty" => {
1690 let _ = writeln!(out, " assert {result_var} != []");
1691 }
1692 "is_empty" => {
1693 let _ = writeln!(out, " assert {result_var} == []");
1694 }
1695 _ => {
1696 let _ = writeln!(
1697 out,
1698 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1699 );
1700 }
1701 }
1702 return;
1703 }
1704 "embedding_dimensions" => {
1705 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1706 match assertion.assertion_type.as_str() {
1707 "equals" => {
1708 if let Some(val) = &assertion.value {
1709 let ex_val = json_to_elixir(val);
1710 let _ = writeln!(out, " assert {expr} == {ex_val}");
1711 }
1712 }
1713 "greater_than" => {
1714 if let Some(val) = &assertion.value {
1715 let ex_val = json_to_elixir(val);
1716 let _ = writeln!(out, " assert {expr} > {ex_val}");
1717 }
1718 }
1719 _ => {
1720 let _ = writeln!(
1721 out,
1722 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1723 );
1724 }
1725 }
1726 return;
1727 }
1728 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1729 let pred = match f.as_str() {
1730 "embeddings_valid" => {
1731 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1732 }
1733 "embeddings_finite" => {
1734 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1735 }
1736 "embeddings_non_zero" => {
1737 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1738 }
1739 "embeddings_normalized" => {
1740 format!(
1741 "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)"
1742 )
1743 }
1744 _ => unreachable!(),
1745 };
1746 match assertion.assertion_type.as_str() {
1747 "is_true" => {
1748 let _ = writeln!(out, " assert {pred}");
1749 }
1750 "is_false" => {
1751 let _ = writeln!(out, " refute {pred}");
1752 }
1753 _ => {
1754 let _ = writeln!(
1755 out,
1756 " # skipped: unsupported assertion type on synthetic field '{f}'"
1757 );
1758 }
1759 }
1760 return;
1761 }
1762 "keywords" | "keywords_count" => {
1765 let _ = writeln!(
1766 out,
1767 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1768 );
1769 return;
1770 }
1771 _ => {}
1772 }
1773 }
1774
1775 if is_streaming {
1778 if let Some(f) = &assertion.field {
1779 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1780 if let Some(expr) =
1781 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "elixir", result_var)
1782 {
1783 match assertion.assertion_type.as_str() {
1784 "count_min" => {
1785 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1786 let _ = writeln!(out, " assert length({expr}) >= {n}");
1787 }
1788 }
1789 "count_equals" => {
1790 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1791 let _ = writeln!(out, " assert length({expr}) == {n}");
1792 }
1793 }
1794 "equals" => {
1795 if let Some(serde_json::Value::String(s)) = &assertion.value {
1796 let escaped = escape_elixir(s);
1797 let _ = writeln!(out, " assert {expr} == \"{escaped}\"");
1798 } else if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1799 let _ = writeln!(out, " assert {expr} == {n}");
1800 }
1801 }
1802 "not_empty" => {
1803 let _ = writeln!(out, " assert {expr} != []");
1804 }
1805 "is_empty" => {
1806 let _ = writeln!(out, " assert {expr} == []");
1807 }
1808 "is_true" => {
1809 let _ = writeln!(out, " assert {expr}");
1810 }
1811 "is_false" => {
1812 let _ = writeln!(out, " refute {expr}");
1813 }
1814 "greater_than" => {
1815 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1816 let _ = writeln!(out, " assert {expr} > {n}");
1817 }
1818 }
1819 "greater_than_or_equal" => {
1820 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1821 let _ = writeln!(out, " assert {expr} >= {n}");
1822 }
1823 }
1824 "contains" => {
1825 if let Some(serde_json::Value::String(s)) = &assertion.value {
1826 let escaped = escape_elixir(s);
1827 let _ = writeln!(out, " assert String.contains?({expr}, \"{escaped}\")");
1828 }
1829 }
1830 _ => {
1831 let _ = writeln!(
1832 out,
1833 " # streaming field '{f}': assertion type '{}' not rendered",
1834 assertion.assertion_type
1835 );
1836 }
1837 }
1838 }
1839 return;
1840 }
1841 }
1842 }
1843
1844 if !result_is_simple {
1849 if let Some(f) = &assertion.field {
1850 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1851 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1852 return;
1853 }
1854 }
1855 }
1856
1857 let field_expr = if result_is_simple {
1861 result_var.to_string()
1862 } else {
1863 match &assertion.field {
1864 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1865 _ => result_var.to_string(),
1866 }
1867 };
1868
1869 let is_numeric = is_numeric_expr(&field_expr);
1872 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1879 let resolved = field_resolver.resolve(f);
1880 fields_enum.contains(f)
1881 || fields_enum.contains(resolved)
1882 || per_call_enum_fields.contains_key(f)
1883 || per_call_enum_fields.contains_key(resolved)
1884 });
1885 let field_is_format_metadata = assertion
1888 .field
1889 .as_deref()
1890 .filter(|f| !f.is_empty())
1891 .is_some_and(|f| f == "metadata.format" || f.ends_with(".metadata.format"));
1892 let coerced_field_expr = if field_is_format_metadata {
1893 format!("alef_e2e_format_to_string({field_expr})")
1894 } else if field_is_enum {
1895 format!("to_string({field_expr})")
1896 } else {
1897 field_expr.clone()
1898 };
1899 let trimmed_field_expr = if is_numeric {
1900 field_expr.clone()
1901 } else {
1902 format!("String.trim({coerced_field_expr})")
1903 };
1904
1905 let field_is_array = assertion
1908 .field
1909 .as_deref()
1910 .filter(|f| !f.is_empty())
1911 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1912
1913 match assertion.assertion_type.as_str() {
1914 "equals" => {
1915 if let Some(expected) = &assertion.value {
1916 let elixir_val = json_to_elixir(expected);
1917 let is_string_expected = expected.is_string();
1919 if is_string_expected && !is_numeric {
1920 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1921 } else if field_is_enum {
1922 let _ = writeln!(out, " assert {coerced_field_expr} == {elixir_val}");
1923 } else {
1924 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1925 }
1926 }
1927 }
1928 "contains" => {
1929 if let Some(expected) = &assertion.value {
1930 let elixir_val = json_to_elixir(expected);
1931 if field_is_array && expected.is_string() {
1932 let _ = writeln!(
1934 out,
1935 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1936 );
1937 } else {
1938 let _ = writeln!(
1940 out,
1941 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1942 );
1943 }
1944 }
1945 }
1946 "contains_all" => {
1947 if let Some(values) = &assertion.values {
1948 for val in values {
1949 let elixir_val = json_to_elixir(val);
1950 if field_is_array && val.is_string() {
1951 let _ = writeln!(
1952 out,
1953 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1954 );
1955 } else {
1956 let _ = writeln!(
1957 out,
1958 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1959 );
1960 }
1961 }
1962 }
1963 }
1964 "not_contains" => {
1965 if let Some(expected) = &assertion.value {
1966 let elixir_val = json_to_elixir(expected);
1967 if field_is_array && expected.is_string() {
1968 let _ = writeln!(
1969 out,
1970 " refute Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1971 );
1972 } else {
1973 let _ = writeln!(
1974 out,
1975 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1976 );
1977 }
1978 }
1979 }
1980 "not_empty" => {
1981 let _ = writeln!(out, " assert {field_expr} != \"\"");
1982 }
1983 "is_empty" => {
1984 if is_numeric {
1985 let _ = writeln!(out, " assert {field_expr} == 0");
1987 } else {
1988 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1990 }
1991 }
1992 "contains_any" => {
1993 if let Some(values) = &assertion.values {
1994 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1995 let list_str = items.join(", ");
1996 let _ = writeln!(
1997 out,
1998 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1999 );
2000 }
2001 }
2002 "greater_than" => {
2003 if let Some(val) = &assertion.value {
2004 let elixir_val = json_to_elixir(val);
2005 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
2006 }
2007 }
2008 "less_than" => {
2009 if let Some(val) = &assertion.value {
2010 let elixir_val = json_to_elixir(val);
2011 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
2012 }
2013 }
2014 "greater_than_or_equal" => {
2015 if let Some(val) = &assertion.value {
2016 let elixir_val = json_to_elixir(val);
2017 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
2018 }
2019 }
2020 "less_than_or_equal" => {
2021 if let Some(val) = &assertion.value {
2022 let elixir_val = json_to_elixir(val);
2023 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
2024 }
2025 }
2026 "starts_with" => {
2027 if let Some(expected) = &assertion.value {
2028 let elixir_val = json_to_elixir(expected);
2029 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
2030 }
2031 }
2032 "ends_with" => {
2033 if let Some(expected) = &assertion.value {
2034 let elixir_val = json_to_elixir(expected);
2035 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
2036 }
2037 }
2038 "min_length" => {
2039 if let Some(val) = &assertion.value {
2040 if let Some(n) = val.as_u64() {
2041 let _ = writeln!(
2043 out,
2044 " assert (is_binary({field_expr}) && byte_size({field_expr}) >= {n}) || (is_list({field_expr}) && length({field_expr}) >= {n}) || (is_binary({field_expr}) == false && is_list({field_expr}) == false && String.length({field_expr}) >= {n})"
2045 );
2046 }
2047 }
2048 }
2049 "max_length" => {
2050 if let Some(val) = &assertion.value {
2051 if let Some(n) = val.as_u64() {
2052 let _ = writeln!(
2053 out,
2054 " assert (is_binary({field_expr}) && byte_size({field_expr}) <= {n}) || (is_list({field_expr}) && length({field_expr}) <= {n}) || (is_binary({field_expr}) == false && is_list({field_expr}) == false && String.length({field_expr}) <= {n})"
2055 );
2056 }
2057 }
2058 }
2059 "count_min" => {
2060 if let Some(val) = &assertion.value {
2061 if let Some(n) = val.as_u64() {
2062 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
2063 }
2064 }
2065 }
2066 "count_equals" => {
2067 if let Some(val) = &assertion.value {
2068 if let Some(n) = val.as_u64() {
2069 let _ = writeln!(out, " assert length({field_expr}) == {n}");
2070 }
2071 }
2072 }
2073 "is_true" => {
2074 let _ = writeln!(out, " assert {field_expr} == true");
2075 }
2076 "is_false" => {
2077 let _ = writeln!(out, " assert {field_expr} == false");
2078 }
2079 "method_result" => {
2080 if let Some(method_name) = &assertion.method {
2081 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
2082 let check = assertion.check.as_deref().unwrap_or("is_true");
2083 match check {
2084 "equals" => {
2085 if let Some(val) = &assertion.value {
2086 let elixir_val = json_to_elixir(val);
2087 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
2088 }
2089 }
2090 "is_true" => {
2091 let _ = writeln!(out, " assert {call_expr} == true");
2092 }
2093 "is_false" => {
2094 let _ = writeln!(out, " assert {call_expr} == false");
2095 }
2096 "greater_than_or_equal" => {
2097 if let Some(val) = &assertion.value {
2098 let n = val.as_u64().unwrap_or(0);
2099 let _ = writeln!(out, " assert {call_expr} >= {n}");
2100 }
2101 }
2102 "count_min" => {
2103 if let Some(val) = &assertion.value {
2104 let n = val.as_u64().unwrap_or(0);
2105 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
2106 }
2107 }
2108 "contains" => {
2109 if let Some(val) = &assertion.value {
2110 let elixir_val = json_to_elixir(val);
2111 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
2112 }
2113 }
2114 "is_error" => {
2115 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
2116 }
2117 other_check => {
2118 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
2119 }
2120 }
2121 } else {
2122 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
2123 }
2124 }
2125 "matches_regex" => {
2126 if let Some(expected) = &assertion.value {
2127 let elixir_val = json_to_elixir(expected);
2128 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
2129 }
2130 }
2131 "not_error" => {
2132 }
2134 "error" => {
2135 }
2137 other => {
2138 panic!("Elixir e2e generator: unsupported assertion type: {other}");
2139 }
2140 }
2141}
2142
2143fn build_elixir_method_call(
2146 result_var: &str,
2147 method_name: &str,
2148 args: Option<&serde_json::Value>,
2149 module_path: &str,
2150) -> String {
2151 match method_name {
2152 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
2153 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
2154 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
2155 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
2156 "contains_node_type" => {
2157 let node_type = args
2158 .and_then(|a| a.get("node_type"))
2159 .and_then(|v| v.as_str())
2160 .unwrap_or("");
2161 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
2162 }
2163 "find_nodes_by_type" => {
2164 let node_type = args
2165 .and_then(|a| a.get("node_type"))
2166 .and_then(|v| v.as_str())
2167 .unwrap_or("");
2168 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
2169 }
2170 "run_query" => {
2171 let query_source = args
2172 .and_then(|a| a.get("query_source"))
2173 .and_then(|v| v.as_str())
2174 .unwrap_or("");
2175 let language = args
2176 .and_then(|a| a.get("language"))
2177 .and_then(|v| v.as_str())
2178 .unwrap_or("");
2179 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
2180 }
2181 _ => format!("{module_path}.{method_name}({result_var})"),
2182 }
2183}
2184
2185fn elixir_module_name(category: &str) -> String {
2187 use heck::ToUpperCamelCase;
2188 category.to_upper_camel_case()
2189}
2190
2191fn json_to_elixir(value: &serde_json::Value) -> String {
2193 match value {
2194 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
2195 serde_json::Value::Bool(true) => "true".to_string(),
2196 serde_json::Value::Bool(false) => "false".to_string(),
2197 serde_json::Value::Number(n) => {
2198 let s = n.to_string().replace("e+", "e");
2202 if s.contains('e') && !s.contains('.') {
2203 s.replacen('e', ".0e", 1)
2205 } else {
2206 s
2207 }
2208 }
2209 serde_json::Value::Null => "nil".to_string(),
2210 serde_json::Value::Array(arr) => {
2211 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
2212 format!("[{}]", items.join(", "))
2213 }
2214 serde_json::Value::Object(map) => {
2215 let entries: Vec<String> = map
2216 .iter()
2217 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
2218 .collect();
2219 format!("%{{{}}}", entries.join(", "))
2220 }
2221 }
2222}
2223
2224fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
2226 use std::fmt::Write as FmtWrite;
2227 let mut visitor_obj = String::new();
2228 let _ = writeln!(visitor_obj, "%{{");
2229 for (method_name, action) in &visitor_spec.callbacks {
2230 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
2231 }
2232 let _ = writeln!(visitor_obj, " }}");
2233
2234 setup_lines.push(format!("visitor = {visitor_obj}"));
2235 "visitor".to_string()
2236}
2237
2238fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
2240 use std::fmt::Write as FmtWrite;
2241
2242 let handle_method = format!("handle_{}", &method_name[6..]); let arg_binding = match action {
2252 CallbackAction::CustomTemplate { .. } => "args",
2253 _ => "_args",
2254 };
2255 let _ = writeln!(out, " :{handle_method} => fn({arg_binding}) ->");
2256 match action {
2257 CallbackAction::Skip => {
2258 let _ = writeln!(out, " :skip");
2259 }
2260 CallbackAction::Continue => {
2261 let _ = writeln!(out, " :continue");
2262 }
2263 CallbackAction::PreserveHtml => {
2264 let _ = writeln!(out, " :preserve_html");
2265 }
2266 CallbackAction::Custom { output } => {
2267 let escaped = escape_elixir(output);
2268 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
2269 }
2270 CallbackAction::CustomTemplate { template, .. } => {
2271 let expr = template_to_elixir_concat(template);
2275 let _ = writeln!(out, " {{:custom, {expr}}}");
2276 }
2277 }
2278 let _ = writeln!(out, " end,");
2279}
2280
2281fn template_to_elixir_concat(template: &str) -> String {
2286 let mut parts: Vec<String> = Vec::new();
2287 let mut static_buf = String::new();
2288 let mut chars = template.chars().peekable();
2289
2290 while let Some(ch) = chars.next() {
2291 if ch == '{' {
2292 let mut key = String::new();
2293 let mut closed = false;
2294 for kc in chars.by_ref() {
2295 if kc == '}' {
2296 closed = true;
2297 break;
2298 }
2299 key.push(kc);
2300 }
2301 if closed && !key.is_empty() {
2302 if !static_buf.is_empty() {
2303 let escaped = escape_elixir(&static_buf);
2304 parts.push(format!("\"{escaped}\""));
2305 static_buf.clear();
2306 }
2307 let escaped_key = escape_elixir(&key);
2308 parts.push(format!("Map.get(args, \"{escaped_key}\", \"\")"));
2309 } else {
2310 static_buf.push('{');
2311 static_buf.push_str(&key);
2312 if !closed {
2313 }
2315 }
2316 } else {
2317 static_buf.push(ch);
2318 }
2319 }
2320
2321 if !static_buf.is_empty() {
2322 let escaped = escape_elixir(&static_buf);
2323 parts.push(format!("\"{escaped}\""));
2324 }
2325
2326 if parts.is_empty() {
2327 return "\"\"".to_string();
2328 }
2329 parts.join(" <> ")
2330}
2331
2332fn fixture_has_elixir_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
2333 if fixture.is_http_test() {
2335 return false;
2336 }
2337 let call_config = e2e_config.resolve_call_for_fixture(
2338 fixture.call.as_deref(),
2339 &fixture.id,
2340 &fixture.resolved_category(),
2341 &fixture.tags,
2342 &fixture.input,
2343 );
2344 let elixir_override = call_config
2345 .overrides
2346 .get("elixir")
2347 .or_else(|| e2e_config.call.overrides.get("elixir"));
2348 if elixir_override.and_then(|o| o.client_factory.as_deref()).is_some() {
2350 return true;
2351 }
2352 let function_from_override = elixir_override.and_then(|o| o.function.as_deref());
2357
2358 function_from_override.is_some() || !call_config.function.is_empty()
2360}