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 _ = writeln!(out);
405
406 for (i, fixture) in fixtures.iter().enumerate() {
407 if let Some(http) = &fixture.http {
408 render_http_test_case(&mut out, fixture, http);
409 } else {
410 render_test_case(
411 &mut out,
412 fixture,
413 e2e_config,
414 module_path,
415 function_name,
416 result_var,
417 args,
418 options_type,
419 options_default_fn,
420 enum_fields,
421 handle_struct_type,
422 handle_atom_list_fields,
423 adapters,
424 );
425 }
426 if i + 1 < fixtures.len() {
427 let _ = writeln!(out);
428 }
429 }
430
431 let _ = writeln!(out, "end");
432 out
433}
434
435const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
442
443const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
446
447struct ElixirTestClientRenderer<'a> {
451 fixture_id: &'a str,
454 expected_status: u16,
456}
457
458impl<'a> client::TestClientRenderer for ElixirTestClientRenderer<'a> {
459 fn language_name(&self) -> &'static str {
460 "elixir"
461 }
462
463 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
469 let escaped_description = description.replace('"', "\\\"");
470 let _ = writeln!(out, " describe \"{fn_name}\" do");
471 if skip_reason.is_some() {
472 let _ = writeln!(out, " @tag :skip");
473 }
474 let _ = writeln!(out, " test \"{escaped_description}\" do");
475 }
476
477 fn render_test_close(&self, out: &mut String) {
479 let _ = writeln!(out, " end");
480 let _ = writeln!(out, " end");
481 }
482
483 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
485 let method = ctx.method.to_lowercase();
486 let mut opts: Vec<String> = Vec::new();
487
488 if let Some(body) = ctx.body {
489 let elixir_val = json_to_elixir(body);
490 opts.push(format!("json: {elixir_val}"));
491 }
492
493 if !ctx.headers.is_empty() {
494 let header_pairs: Vec<String> = ctx
495 .headers
496 .iter()
497 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
498 .collect();
499 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
500 }
501
502 if !ctx.cookies.is_empty() {
503 let cookie_str = ctx
504 .cookies
505 .iter()
506 .map(|(k, v)| format!("{k}={v}"))
507 .collect::<Vec<_>>()
508 .join("; ");
509 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
510 }
511
512 if !ctx.query_params.is_empty() {
513 let pairs: Vec<String> = ctx
514 .query_params
515 .iter()
516 .map(|(k, v)| {
517 let val_str = match v {
518 serde_json::Value::String(s) => s.clone(),
519 other => other.to_string(),
520 };
521 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
522 })
523 .collect();
524 opts.push(format!("params: [{}]", pairs.join(", ")));
525 }
526
527 if (300..400).contains(&self.expected_status) {
530 opts.push("redirect: false".to_string());
531 }
532
533 let fixture_id = escape_elixir(self.fixture_id);
534 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{fixture_id}\"");
535
536 if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
537 if opts.is_empty() {
538 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
539 } else {
540 let opts_str = opts.join(", ");
541 let _ = writeln!(
542 out,
543 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
544 );
545 }
546 } else {
547 opts.insert(0, format!("method: :{method}"));
548 opts.insert(1, format!("url: {url_expr}"));
549 let opts_str = opts.join(", ");
550 let _ = writeln!(out, " {{:ok, response}} = Req.request({opts_str})");
551 }
552 }
553
554 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
555 let _ = writeln!(out, " assert {response_var}.status == {status}");
556 }
557
558 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
563 let header_key = name.to_lowercase();
564 if header_key == "connection" {
566 return;
567 }
568 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
569 let get_header_expr = format!(
570 "Enum.find_value({response_var}.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
571 );
572 match expected {
573 "<<present>>" => {
574 let _ = writeln!(out, " assert {get_header_expr} != nil");
575 }
576 "<<absent>>" => {
577 let _ = writeln!(out, " assert {get_header_expr} == nil");
578 }
579 "<<uuid>>" => {
580 let var = sanitize_ident(&header_key);
581 let _ = writeln!(out, " header_val_{var} = {get_header_expr}");
582 let _ = writeln!(
583 out,
584 " 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}))"
585 );
586 }
587 literal => {
588 let val_lit = format!("\"{}\"", escape_elixir(literal));
589 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
590 }
591 }
592 }
593
594 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
599 let elixir_val = json_to_elixir(expected);
600 match expected {
601 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
602 let _ = writeln!(
603 out,
604 " body_decoded = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
605 );
606 let _ = writeln!(out, " assert body_decoded == {elixir_val}");
607 }
608 _ => {
609 let _ = writeln!(out, " assert {response_var}.body == {elixir_val}");
610 }
611 }
612 }
613
614 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
616 if let Some(obj) = expected.as_object() {
617 let _ = writeln!(
618 out,
619 " decoded_body = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
620 );
621 for (key, val) in obj {
622 let key_lit = format!("\"{}\"", escape_elixir(key));
623 let elixir_val = json_to_elixir(val);
624 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
625 }
626 }
627 }
628
629 fn render_assert_validation_errors(
632 &self,
633 out: &mut String,
634 response_var: &str,
635 errors: &[ValidationErrorExpectation],
636 ) {
637 for err in errors {
638 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
639 let _ = writeln!(
640 out,
641 " assert String.contains?(Jason.encode!({response_var}.body), {msg_lit})"
642 );
643 }
644 }
645}
646
647fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
653 let method = http.request.method.to_uppercase();
654
655 if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
659 let test_name = sanitize_ident(&fixture.id);
660 let test_label = fixture.id.replace('"', "\\\"");
661 let path = &http.request.path;
662 let _ = writeln!(out, " describe \"{test_name}\" do");
663 let _ = writeln!(out, " @tag :skip");
664 let _ = writeln!(out, " test \"{method} {path} - {test_label}\" do");
665 let _ = writeln!(out, " end");
666 let _ = writeln!(out, " end");
667 return;
668 }
669
670 let renderer = ElixirTestClientRenderer {
671 fixture_id: &fixture.id,
672 expected_status: http.expected_response.status_code,
673 };
674 client::http_call::render_http_test(out, &renderer, fixture);
675}
676
677#[allow(clippy::too_many_arguments)]
682fn render_test_case(
683 out: &mut String,
684 fixture: &Fixture,
685 e2e_config: &E2eConfig,
686 default_module_path: &str,
687 default_function_name: &str,
688 default_result_var: &str,
689 args: &[crate::config::ArgMapping],
690 options_type: Option<&str>,
691 options_default_fn: Option<&str>,
692 enum_fields: &HashMap<String, String>,
693 handle_struct_type: Option<&str>,
694 handle_atom_list_fields: &std::collections::HashSet<String>,
695 adapters: &[alef_core::config::extras::AdapterConfig],
696) {
697 let test_name = sanitize_ident(&fixture.id);
698 let test_label = fixture.id.replace('"', "\\\"");
699
700 if fixture.mock_response.is_none() && !fixture_has_elixir_callable(fixture, e2e_config) {
706 let _ = writeln!(out, " describe \"{test_name}\" do");
707 let _ = writeln!(out, " @tag :skip");
708 let _ = writeln!(out, " test \"{test_label}\" do");
709 let _ = writeln!(
710 out,
711 " # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
712 );
713 let _ = writeln!(out, " :ok");
714 let _ = writeln!(out, " end");
715 let _ = writeln!(out, " end");
716 return;
717 }
718
719 let call_config = e2e_config.resolve_call_for_fixture(
721 fixture.call.as_deref(),
722 &fixture.id,
723 &fixture.resolved_category(),
724 &fixture.tags,
725 &fixture.input,
726 );
727 let call_field_resolver = FieldResolver::new(
729 e2e_config.effective_fields(call_config),
730 e2e_config.effective_fields_optional(call_config),
731 e2e_config.effective_result_fields(call_config),
732 e2e_config.effective_fields_array(call_config),
733 &std::collections::HashSet::new(),
734 );
735 let field_resolver = &call_field_resolver;
736 let lang = "elixir";
737 let call_overrides = call_config.overrides.get(lang);
738
739 let base_fn = call_overrides
742 .and_then(|o| o.function.as_ref())
743 .cloned()
744 .unwrap_or_else(|| call_config.function.clone());
745 if base_fn.starts_with("batch_extract_") {
746 let _ = writeln!(
747 out,
748 " describe \"{test_name}\" do",
749 test_name = sanitize_ident(&fixture.id)
750 );
751 let _ = writeln!(out, " @tag :skip");
752 let _ = writeln!(
753 out,
754 " test \"{test_label}\" do",
755 test_label = fixture.id.replace('"', "\\\"")
756 );
757 let _ = writeln!(
758 out,
759 " # batch functions excluded from Elixir binding: unsafe NIF tuple marshalling"
760 );
761 let _ = writeln!(out, " :ok");
762 let _ = writeln!(out, " end");
763 let _ = writeln!(out, " end");
764 return;
765 }
766
767 let (module_path, function_name, result_var) = if fixture.call.is_some() {
770 let raw_module = call_overrides
771 .and_then(|o| o.module.as_ref())
772 .cloned()
773 .unwrap_or_else(|| call_config.module.clone());
774 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
775 {
776 raw_module.clone()
777 } else {
778 elixir_module_name(&raw_module)
779 };
780 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") && !base_fn.ends_with("_stream") {
781 format!("{base_fn}_async")
782 } else {
783 base_fn
784 };
785 (resolved_module, resolved_fn, call_config.result_var.clone())
786 } else {
787 (
788 default_module_path.to_string(),
789 default_function_name.to_string(),
790 default_result_var.to_string(),
791 )
792 };
793
794 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
795 let validation_creation_failure = expects_error && fixture.resolved_category() == "validation";
799
800 let (
802 effective_args,
803 effective_options_type,
804 effective_options_default_fn,
805 effective_enum_fields,
806 effective_handle_struct_type,
807 effective_handle_atom_list_fields,
808 );
809 let empty_enum_fields_local: HashMap<String, String>;
810 let empty_atom_fields_local: std::collections::HashSet<String>;
811 let (
812 resolved_args,
813 resolved_options_type,
814 resolved_options_default_fn,
815 resolved_enum_fields_ref,
816 resolved_handle_struct_type,
817 resolved_handle_atom_list_fields_ref,
818 ) = if fixture.call.is_some() {
819 let co = call_config.overrides.get(lang);
820 effective_args = call_config.args.as_slice();
821 effective_options_type = co.and_then(|o| o.options_type.as_deref());
822 effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
823 empty_enum_fields_local = HashMap::new();
824 effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
825 effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
826 empty_atom_fields_local = std::collections::HashSet::new();
827 effective_handle_atom_list_fields = co
828 .map(|o| &o.handle_atom_list_fields)
829 .unwrap_or(&empty_atom_fields_local);
830 (
831 effective_args,
832 effective_options_type,
833 effective_options_default_fn,
834 effective_enum_fields,
835 effective_handle_struct_type,
836 effective_handle_atom_list_fields,
837 )
838 } else {
839 (
840 args as &[_],
841 options_type,
842 options_default_fn,
843 enum_fields,
844 handle_struct_type,
845 handle_atom_list_fields,
846 )
847 };
848
849 let test_documents_path = e2e_config.test_documents_relative_from(0);
850 let adapter_request_type: Option<String> = adapters
851 .iter()
852 .find(|a| a.name == call_config.function.as_str())
853 .and_then(|a| a.request_type.as_deref())
854 .map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());
855 let (mut setup_lines, args_str) = build_args_and_setup(
856 &fixture.input,
857 resolved_args,
858 &module_path,
859 resolved_options_type,
860 resolved_options_default_fn,
861 resolved_enum_fields_ref,
862 fixture,
863 resolved_handle_struct_type,
864 resolved_handle_atom_list_fields_ref,
865 &test_documents_path,
866 adapter_request_type.as_deref(),
867 );
868
869 let visitor_var = fixture
871 .visitor
872 .as_ref()
873 .map(|visitor_spec| build_elixir_visitor(&mut setup_lines, visitor_spec));
874
875 let final_args = if let Some(ref visitor_var) = visitor_var {
878 let parts: Vec<&str> = args_str.split(", ").collect();
882 if parts.len() == 2 && parts[1] == "nil" {
883 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
885 } else if parts.len() == 2 {
886 setup_lines.push(format!(
888 "{} = Map.put({}, :visitor, {})",
889 parts[1], parts[1], visitor_var
890 ));
891 args_str
892 } else if parts.len() == 1 {
893 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
895 } else {
896 args_str
897 }
898 } else {
899 args_str
900 };
901
902 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
904 e2e_config
905 .call
906 .overrides
907 .get("elixir")
908 .and_then(|o| o.client_factory.as_deref())
909 });
910
911 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
915 let final_args_with_extras = if extra_args.is_empty() {
916 final_args
917 } else if final_args.is_empty() {
918 extra_args.join(", ")
919 } else {
920 format!("{final_args}, {}", extra_args.join(", "))
921 };
922
923 let effective_args = if client_factory.is_some() {
925 if final_args_with_extras.is_empty() {
926 "client".to_string()
927 } else {
928 format!("client, {final_args_with_extras}")
929 }
930 } else {
931 final_args_with_extras
932 };
933
934 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
938 let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
939 let needs_api_key_skip = !has_mock && api_key_var_opt.is_some();
940 let needs_env_fallback = has_mock && api_key_var_opt.is_some();
943
944 let _ = writeln!(out, " describe \"{test_name}\" do");
945 let _ = writeln!(out, " test \"{test_label}\" do");
946
947 if needs_api_key_skip {
948 let api_key_var = api_key_var_opt.unwrap_or("");
949 let _ = writeln!(out, " if System.get_env(\"{api_key_var}\") in [nil, \"\"] do");
950 let _ = writeln!(out, " # {api_key_var} not set — skipping live smoke test");
951 let _ = writeln!(out, " :ok");
952 let _ = writeln!(out, " else");
953 }
954
955 if validation_creation_failure {
959 let mut emitted_error_assertion = false;
960 for line in &setup_lines {
961 if !emitted_error_assertion && line.starts_with("{:ok,") {
962 if let Some(rhs) = line.split_once('=').map(|x| x.1) {
963 let rhs = rhs.trim();
964 let _ = writeln!(out, " assert {{:error, _}} = {rhs}");
965 emitted_error_assertion = true;
966 } else {
967 let _ = writeln!(out, " {line}");
968 }
969 } else {
970 let _ = writeln!(out, " {line}");
971 }
972 }
973 if !emitted_error_assertion {
974 let _ = writeln!(
975 out,
976 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
977 );
978 }
979 if needs_api_key_skip {
980 let _ = writeln!(out, " end");
981 }
982 let _ = writeln!(out, " end");
983 let _ = writeln!(out, " end");
984 return;
985 }
986
987 if expects_error {
994 for line in &setup_lines {
995 let _ = writeln!(out, " {line}");
996 }
997 if let Some(factory) = client_factory {
998 let fixture_id = &fixture.id;
999 let base_url_expr = if fixture.has_host_root_route() {
1000 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1001 format!(
1002 "(System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
1003 )
1004 } else {
1005 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
1006 };
1007 let _ = writeln!(
1008 out,
1009 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", base_url: {base_url_expr})"
1010 );
1011 }
1012 let _ = writeln!(
1013 out,
1014 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
1015 );
1016 if needs_api_key_skip {
1017 let _ = writeln!(out, " end");
1018 }
1019 let _ = writeln!(out, " end");
1020 let _ = writeln!(out, " end");
1021 return;
1022 }
1023
1024 for line in &setup_lines {
1025 let _ = writeln!(out, " {line}");
1026 }
1027
1028 if let Some(factory) = client_factory {
1030 let fixture_id = &fixture.id;
1031 if needs_env_fallback {
1032 let api_key_var = api_key_var_opt.unwrap_or("");
1035 let mock_url_expr = if fixture.has_host_root_route() {
1036 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1037 format!(
1038 "System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\""
1039 )
1040 } else {
1041 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
1042 };
1043 let _ = writeln!(out, " api_key_val = System.get_env(\"{api_key_var}\")");
1044 let _ = writeln!(
1045 out,
1046 " {{api_key_val, client_opts}} = if api_key_val && api_key_val != \"\" do"
1047 );
1048 let _ = writeln!(
1049 out,
1050 " IO.puts(\"{fixture_id}: using real API ({api_key_var} is set)\")"
1051 );
1052 let _ = writeln!(out, " {{api_key_val, []}}");
1053 let _ = writeln!(out, " else");
1054 let _ = writeln!(
1055 out,
1056 " IO.puts(\"{fixture_id}: using mock server ({api_key_var} not set)\")"
1057 );
1058 let _ = writeln!(out, " {{\"test-key\", [base_url: {mock_url_expr}]}}");
1059 let _ = writeln!(out, " end");
1060 let _ = writeln!(
1061 out,
1062 " {{:ok, client}} = {module_path}.{factory}(api_key_val, client_opts)"
1063 );
1064 } else {
1065 let base_url_expr = if fixture.has_host_root_route() {
1066 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1067 format!(
1068 "(System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
1069 )
1070 } else {
1071 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
1072 };
1073 let _ = writeln!(
1074 out,
1075 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", base_url: {base_url_expr})"
1076 );
1077 }
1078 }
1079
1080 let returns_result = call_overrides
1082 .and_then(|o| o.returns_result)
1083 .unwrap_or(call_config.returns_result || client_factory.is_some());
1084
1085 let result_is_simple = call_config.result_is_simple || call_overrides.is_some_and(|o| o.result_is_simple);
1090
1091 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1093 let chunks_var = "chunks";
1095
1096 let actual_result_var = if fixture.assertions.is_empty() && !is_streaming {
1099 format!("_{result_var}")
1100 } else {
1101 result_var.to_string()
1102 };
1103
1104 if returns_result {
1105 let _ = writeln!(
1106 out,
1107 " {{:ok, {actual_result_var}}} = {module_path}.{function_name}({effective_args})"
1108 );
1109 } else {
1110 let _ = writeln!(
1112 out,
1113 " {actual_result_var} = {module_path}.{function_name}({effective_args})"
1114 );
1115 }
1116
1117 if is_streaming {
1119 if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
1120 "elixir",
1121 &result_var,
1122 chunks_var,
1123 ) {
1124 let _ = writeln!(out, " {collect}");
1125 }
1126 }
1127
1128 for assertion in &fixture.assertions {
1129 render_assertion(
1130 out,
1131 assertion,
1132 if is_streaming { chunks_var } else { &result_var },
1133 field_resolver,
1134 &module_path,
1135 e2e_config.effective_fields_enum(call_config),
1136 resolved_enum_fields_ref,
1137 result_is_simple,
1138 is_streaming,
1139 );
1140 }
1141
1142 if needs_api_key_skip {
1143 let _ = writeln!(out, " end");
1144 }
1145 let _ = writeln!(out, " end");
1146 let _ = writeln!(out, " end");
1147}
1148
1149#[allow(clippy::too_many_arguments)]
1153fn emit_elixir_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1155 if let Some(items) = arr.as_array() {
1156 let item_strs: Vec<String> = items
1157 .iter()
1158 .filter_map(|item| {
1159 if let Some(obj) = item.as_object() {
1160 match elem_type {
1161 "BatchBytesItem" => {
1162 let content = obj.get("content").and_then(|v| v.as_array());
1163 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1164 let content_code = if let Some(arr) = content {
1165 let bytes: Vec<String> =
1166 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1167 format!("<<{}>>", bytes.join(", "))
1168 } else {
1169 "<<>>".to_string()
1170 };
1171 Some(format!(
1172 "%BatchBytesItem{{content: {}, mime_type: \"{}\"}}",
1173 content_code, mime_type
1174 ))
1175 }
1176 "BatchFileItem" => {
1177 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1178 Some(format!("%BatchFileItem{{path: \"{}\"}}", path))
1179 }
1180 _ => None,
1181 }
1182 } else {
1183 None
1184 }
1185 })
1186 .collect();
1187 format!("[{}]", item_strs.join(", "))
1188 } else {
1189 "[]".to_string()
1190 }
1191}
1192
1193#[allow(clippy::too_many_arguments)]
1194fn build_args_and_setup(
1195 input: &serde_json::Value,
1196 args: &[crate::config::ArgMapping],
1197 module_path: &str,
1198 options_type: Option<&str>,
1199 options_default_fn: Option<&str>,
1200 enum_fields: &HashMap<String, String>,
1201 fixture: &crate::fixture::Fixture,
1202 _handle_struct_type: Option<&str>,
1203 _handle_atom_list_fields: &std::collections::HashSet<String>,
1204 test_documents_path: &str,
1205 adapter_request_type: Option<&str>,
1206) -> (Vec<String>, String) {
1207 let fixture_id = &fixture.id;
1208 if args.is_empty() {
1209 let is_empty_input = match input {
1213 serde_json::Value::Null => true,
1214 serde_json::Value::Object(m) => m.is_empty(),
1215 _ => false,
1216 };
1217 if is_empty_input {
1218 return (Vec::new(), String::new());
1219 }
1220 return (Vec::new(), json_to_elixir(input));
1221 }
1222
1223 let mut setup_lines: Vec<String> = Vec::new();
1224 let mut parts: Vec<String> = Vec::new();
1225
1226 for arg in args {
1227 if arg.arg_type == "mock_url" {
1228 if fixture.has_host_root_route() {
1229 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1230 setup_lines.push(format!(
1231 "{} = System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1232 arg.name,
1233 ));
1234 } else {
1235 setup_lines.push(format!(
1236 "{} = (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1237 arg.name,
1238 ));
1239 }
1240 if let Some(req_type) = adapter_request_type {
1241 let req_var = format!("{}_req", arg.name);
1242 setup_lines.push(format!("{req_var} = %Kreuzcrawl.{req_type}{{url: {}}}", arg.name,));
1243 parts.push(req_var);
1244 } else {
1245 parts.push(arg.name.clone());
1246 }
1247 continue;
1248 }
1249
1250 if arg.arg_type == "mock_url_list" {
1251 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1258 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1259 let val = input.get(field).unwrap_or(&serde_json::Value::Null);
1260 let paths: Vec<String> = if let Some(arr) = val.as_array() {
1261 arr.iter()
1262 .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", escape_elixir(s))))
1263 .collect()
1264 } else {
1265 Vec::new()
1266 };
1267 let paths_literal = paths.join(", ");
1268 let name = &arg.name;
1269 setup_lines.push(format!(
1270 "{name}_base = System.get_env(\"{env_key}\") || ((System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
1271 ));
1272 setup_lines.push(format!(
1273 "{name} = Enum.map([{paths_literal}], fn p -> if String.starts_with?(p, \"http\"), do: p, else: {name}_base <> p end)"
1274 ));
1275 parts.push(name.clone());
1276 continue;
1277 }
1278
1279 if arg.arg_type == "handle" {
1280 let constructor_name = format!("create_{}", arg.name.to_snake_case());
1284 let config_value = if arg.field == "input" {
1285 input
1286 } else {
1287 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1288 input.get(field).unwrap_or(&serde_json::Value::Null)
1289 };
1290 let name = &arg.name;
1291 if config_value.is_null()
1292 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1293 {
1294 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
1295 } else {
1296 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
1299 let escaped = escape_elixir(&json_str);
1300 setup_lines.push(format!("{name}_config = \"{escaped}\""));
1301 setup_lines.push(format!(
1302 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
1303 ));
1304 }
1305 parts.push(arg.name.clone());
1306 continue;
1307 }
1308
1309 let val = if arg.field == "input" {
1310 Some(input)
1311 } else {
1312 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1313 input.get(field)
1314 };
1315 match val {
1316 None | Some(serde_json::Value::Null) if arg.optional => {
1317 continue;
1320 }
1321 None | Some(serde_json::Value::Null) => {
1322 let default_val = match arg.arg_type.as_str() {
1324 "string" => "\"\"".to_string(),
1325 "int" | "integer" => "0".to_string(),
1326 "float" | "number" => "0.0".to_string(),
1327 "bool" | "boolean" => "false".to_string(),
1328 _ => "nil".to_string(),
1329 };
1330 parts.push(default_val);
1331 }
1332 Some(v) => {
1333 if arg.arg_type == "file_path" {
1336 if let Some(path_str) = v.as_str() {
1337 let full_path = format!("{test_documents_path}/{path_str}");
1338 let formatted = format!("\"{}\"", escape_elixir(&full_path));
1339 if arg.optional {
1340 parts.push(format!("{}: {formatted}", arg.name));
1341 } else {
1342 parts.push(formatted);
1343 }
1344 continue;
1345 }
1346 }
1347 if arg.arg_type == "bytes" {
1350 if let Some(raw) = v.as_str() {
1351 let var_name = &arg.name;
1352 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
1353 let formatted = format!("\"{}\"", escape_elixir(raw));
1355 if arg.optional {
1356 parts.push(format!("{}: {formatted}", arg.name));
1357 } else {
1358 parts.push(formatted);
1359 }
1360 } else {
1361 let first = raw.chars().next().unwrap_or('\0');
1362 let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
1363 && raw
1364 .find('/')
1365 .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
1366 if is_file_path {
1367 let full_path = format!("{test_documents_path}/{raw}");
1370 let escaped = escape_elixir(&full_path);
1371 setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
1372 if arg.optional {
1373 parts.push(format!("{}: {var_name}", arg.name));
1374 } else {
1375 parts.push(var_name.to_string());
1376 }
1377 } else {
1378 setup_lines.push(format!(
1380 "{var_name} = Base.decode64!(\"{}\", padding: false)",
1381 escape_elixir(raw)
1382 ));
1383 if arg.optional {
1384 parts.push(format!("{}: {var_name}", arg.name));
1385 } else {
1386 parts.push(var_name.to_string());
1387 }
1388 }
1389 }
1390 continue;
1391 }
1392 }
1393 if arg.arg_type == "json_object" && !v.is_null() {
1395 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
1396 (options_type, options_default_fn, v.as_object())
1397 {
1398 let options_var = "options";
1400 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
1401
1402 for (k, vv) in obj.iter() {
1404 let snake_key = k.to_snake_case();
1405 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1406 if let Some(s) = vv.as_str() {
1407 let snake_val = s.to_snake_case();
1408 format!(":{snake_val}")
1410 } else {
1411 json_to_elixir(vv)
1412 }
1413 } else {
1414 json_to_elixir(vv)
1415 };
1416 setup_lines.push(format!(
1417 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
1418 ));
1419 }
1420
1421 parts.push(format!("{}: {options_var}", arg.name));
1425 continue;
1426 }
1427 if let (Some(opts_type), None, Some(obj)) = (options_type, options_default_fn, v.as_object()) {
1429 let options_var = "options";
1430 let mut field_strs = Vec::new();
1431 for (k, vv) in obj.iter() {
1432 let snake_key = k.to_snake_case();
1433 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1434 if let Some(s) = vv.as_str() {
1435 let snake_val = s.to_snake_case();
1436 format!(":{snake_val}")
1437 } else {
1438 json_to_elixir(vv)
1439 }
1440 } else {
1441 json_to_elixir(vv)
1442 };
1443 field_strs.push(format!("{snake_key}: {elixir_val}"));
1444 }
1445 let fields = field_strs.join(", ");
1446 setup_lines.push(format!("{options_var} = %{module_path}.{opts_type}{{{fields}}}"));
1447 parts.push(format!("{}: {options_var}", arg.name));
1450 continue;
1451 }
1452 if let Some(elem_type) = &arg.element_type {
1454 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1455 let formatted = emit_elixir_batch_item_array(v, elem_type);
1456 if arg.optional {
1457 parts.push(format!("{}: {formatted}", arg.name));
1458 } else {
1459 parts.push(formatted);
1460 }
1461 continue;
1462 }
1463 if v.is_array() {
1466 let formatted = json_to_elixir(v);
1467 if arg.optional {
1468 parts.push(format!("{}: {formatted}", arg.name));
1469 } else {
1470 parts.push(formatted);
1471 }
1472 continue;
1473 }
1474 }
1475 if !v.is_null() {
1479 let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
1480 let escaped = escape_elixir(&json_str);
1481 let formatted = format!("\"{escaped}\"");
1482 if arg.optional {
1483 parts.push(format!("{}: {formatted}", arg.name));
1484 } else {
1485 parts.push(formatted);
1486 }
1487 continue;
1488 }
1489 }
1490 let elixir_val = json_to_elixir(v);
1492 if arg.optional {
1493 parts.push(format!("{}: {elixir_val}", arg.name));
1494 } else {
1495 parts.push(elixir_val);
1496 }
1497 }
1498 }
1499 }
1500
1501 let mut positional_args = Vec::new();
1504 let mut keyword_args = Vec::new();
1505 let mut seen_keyword = false;
1506
1507 for (idx, part) in parts.into_iter().enumerate() {
1508 let is_keyword = part.contains(": ") && !part.starts_with('"');
1509 if is_keyword {
1510 seen_keyword = true;
1511 keyword_args.push((idx, part));
1512 } else if seen_keyword {
1513 keyword_args.push((idx, part));
1518 } else {
1519 positional_args.push(part);
1520 }
1521 }
1522
1523 let mut final_args = positional_args;
1524 final_args.extend(keyword_args.into_iter().map(|(_, arg)| arg));
1525
1526 (setup_lines, final_args.join(", "))
1527}
1528
1529fn is_numeric_expr(field_expr: &str) -> bool {
1532 field_expr.starts_with("length(")
1533}
1534
1535#[allow(clippy::too_many_arguments)]
1536fn render_assertion(
1537 out: &mut String,
1538 assertion: &Assertion,
1539 result_var: &str,
1540 field_resolver: &FieldResolver,
1541 module_path: &str,
1542 fields_enum: &std::collections::HashSet<String>,
1543 per_call_enum_fields: &HashMap<String, String>,
1544 result_is_simple: bool,
1545 is_streaming: bool,
1546) {
1547 if let Some(f) = &assertion.field {
1550 match f.as_str() {
1551 "chunks_have_content" => {
1552 let pred =
1553 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
1554 match assertion.assertion_type.as_str() {
1555 "is_true" => {
1556 let _ = writeln!(out, " assert {pred}");
1557 }
1558 "is_false" => {
1559 let _ = writeln!(out, " refute {pred}");
1560 }
1561 _ => {
1562 let _ = writeln!(
1563 out,
1564 " # skipped: unsupported assertion type on synthetic field '{f}'"
1565 );
1566 }
1567 }
1568 return;
1569 }
1570 "chunks_have_embeddings" => {
1571 let pred = format!(
1572 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
1573 );
1574 match assertion.assertion_type.as_str() {
1575 "is_true" => {
1576 let _ = writeln!(out, " assert {pred}");
1577 }
1578 "is_false" => {
1579 let _ = writeln!(out, " refute {pred}");
1580 }
1581 _ => {
1582 let _ = writeln!(
1583 out,
1584 " # skipped: unsupported assertion type on synthetic field '{f}'"
1585 );
1586 }
1587 }
1588 return;
1589 }
1590 "chunks_have_heading_context" => {
1591 let pred = format!(
1592 "Enum.all?({result_var}.chunks || [], fn c -> c.metadata != nil and c.metadata.heading_context != nil end)"
1593 );
1594 match assertion.assertion_type.as_str() {
1595 "is_true" => {
1596 let _ = writeln!(out, " assert {pred}");
1597 }
1598 "is_false" => {
1599 let _ = writeln!(out, " refute {pred}");
1600 }
1601 _ => {
1602 let _ = writeln!(
1603 out,
1604 " # skipped: unsupported assertion type on synthetic field '{f}'"
1605 );
1606 }
1607 }
1608 return;
1609 }
1610 "first_chunk_starts_with_heading" => {
1611 let expr = format!(
1612 "case List.first({result_var}.chunks || []) do
1613 c when is_map(c) -> String.trim_leading(c.content || \"\") |> String.starts_with?(\"#\")
1614 _ -> false
1615 end"
1616 );
1617 match assertion.assertion_type.as_str() {
1618 "is_true" => {
1619 let _ = writeln!(out, " assert ({expr})");
1620 }
1621 "is_false" => {
1622 let _ = writeln!(out, " refute ({expr})");
1623 }
1624 _ => {
1625 let _ = writeln!(
1626 out,
1627 " # skipped: unsupported assertion type on synthetic field '{f}'"
1628 );
1629 }
1630 }
1631 return;
1632 }
1633 "embeddings" => {
1637 match assertion.assertion_type.as_str() {
1638 "count_equals" => {
1639 if let Some(val) = &assertion.value {
1640 let ex_val = json_to_elixir(val);
1641 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
1642 }
1643 }
1644 "count_min" => {
1645 if let Some(val) = &assertion.value {
1646 let ex_val = json_to_elixir(val);
1647 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
1648 }
1649 }
1650 "not_empty" => {
1651 let _ = writeln!(out, " assert {result_var} != []");
1652 }
1653 "is_empty" => {
1654 let _ = writeln!(out, " assert {result_var} == []");
1655 }
1656 _ => {
1657 let _ = writeln!(
1658 out,
1659 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1660 );
1661 }
1662 }
1663 return;
1664 }
1665 "embedding_dimensions" => {
1666 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1667 match assertion.assertion_type.as_str() {
1668 "equals" => {
1669 if let Some(val) = &assertion.value {
1670 let ex_val = json_to_elixir(val);
1671 let _ = writeln!(out, " assert {expr} == {ex_val}");
1672 }
1673 }
1674 "greater_than" => {
1675 if let Some(val) = &assertion.value {
1676 let ex_val = json_to_elixir(val);
1677 let _ = writeln!(out, " assert {expr} > {ex_val}");
1678 }
1679 }
1680 _ => {
1681 let _ = writeln!(
1682 out,
1683 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1684 );
1685 }
1686 }
1687 return;
1688 }
1689 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1690 let pred = match f.as_str() {
1691 "embeddings_valid" => {
1692 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1693 }
1694 "embeddings_finite" => {
1695 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1696 }
1697 "embeddings_non_zero" => {
1698 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1699 }
1700 "embeddings_normalized" => {
1701 format!(
1702 "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)"
1703 )
1704 }
1705 _ => unreachable!(),
1706 };
1707 match assertion.assertion_type.as_str() {
1708 "is_true" => {
1709 let _ = writeln!(out, " assert {pred}");
1710 }
1711 "is_false" => {
1712 let _ = writeln!(out, " refute {pred}");
1713 }
1714 _ => {
1715 let _ = writeln!(
1716 out,
1717 " # skipped: unsupported assertion type on synthetic field '{f}'"
1718 );
1719 }
1720 }
1721 return;
1722 }
1723 "keywords" | "keywords_count" => {
1726 let _ = writeln!(
1727 out,
1728 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1729 );
1730 return;
1731 }
1732 _ => {}
1733 }
1734 }
1735
1736 if is_streaming {
1739 if let Some(f) = &assertion.field {
1740 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1741 if let Some(expr) =
1742 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "elixir", result_var)
1743 {
1744 match assertion.assertion_type.as_str() {
1745 "count_min" => {
1746 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1747 let _ = writeln!(out, " assert length({expr}) >= {n}");
1748 }
1749 }
1750 "count_equals" => {
1751 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1752 let _ = writeln!(out, " assert length({expr}) == {n}");
1753 }
1754 }
1755 "equals" => {
1756 if let Some(serde_json::Value::String(s)) = &assertion.value {
1757 let escaped = escape_elixir(s);
1758 let _ = writeln!(out, " assert {expr} == \"{escaped}\"");
1759 } else if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1760 let _ = writeln!(out, " assert {expr} == {n}");
1761 }
1762 }
1763 "not_empty" => {
1764 let _ = writeln!(out, " assert {expr} != []");
1765 }
1766 "is_empty" => {
1767 let _ = writeln!(out, " assert {expr} == []");
1768 }
1769 "is_true" => {
1770 let _ = writeln!(out, " assert {expr}");
1771 }
1772 "is_false" => {
1773 let _ = writeln!(out, " refute {expr}");
1774 }
1775 "greater_than" => {
1776 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1777 let _ = writeln!(out, " assert {expr} > {n}");
1778 }
1779 }
1780 "greater_than_or_equal" => {
1781 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1782 let _ = writeln!(out, " assert {expr} >= {n}");
1783 }
1784 }
1785 "contains" => {
1786 if let Some(serde_json::Value::String(s)) = &assertion.value {
1787 let escaped = escape_elixir(s);
1788 let _ = writeln!(out, " assert String.contains?({expr}, \"{escaped}\")");
1789 }
1790 }
1791 _ => {
1792 let _ = writeln!(
1793 out,
1794 " # streaming field '{f}': assertion type '{}' not rendered",
1795 assertion.assertion_type
1796 );
1797 }
1798 }
1799 }
1800 return;
1801 }
1802 }
1803 }
1804
1805 if !result_is_simple {
1810 if let Some(f) = &assertion.field {
1811 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1812 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1813 return;
1814 }
1815 }
1816 }
1817
1818 let field_expr = if result_is_simple {
1822 result_var.to_string()
1823 } else {
1824 match &assertion.field {
1825 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1826 _ => result_var.to_string(),
1827 }
1828 };
1829
1830 let is_numeric = is_numeric_expr(&field_expr);
1833 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1840 let resolved = field_resolver.resolve(f);
1841 fields_enum.contains(f)
1842 || fields_enum.contains(resolved)
1843 || per_call_enum_fields.contains_key(f)
1844 || per_call_enum_fields.contains_key(resolved)
1845 });
1846 let coerced_field_expr = if field_is_enum {
1847 format!("to_string({field_expr})")
1848 } else {
1849 field_expr.clone()
1850 };
1851 let trimmed_field_expr = if is_numeric {
1852 field_expr.clone()
1853 } else {
1854 format!("String.trim({coerced_field_expr})")
1855 };
1856
1857 let field_is_array = assertion
1860 .field
1861 .as_deref()
1862 .filter(|f| !f.is_empty())
1863 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1864
1865 match assertion.assertion_type.as_str() {
1866 "equals" => {
1867 if let Some(expected) = &assertion.value {
1868 let elixir_val = json_to_elixir(expected);
1869 let is_string_expected = expected.is_string();
1871 if is_string_expected && !is_numeric {
1872 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1873 } else if field_is_enum {
1874 let _ = writeln!(out, " assert {coerced_field_expr} == {elixir_val}");
1875 } else {
1876 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1877 }
1878 }
1879 }
1880 "contains" => {
1881 if let Some(expected) = &assertion.value {
1882 let elixir_val = json_to_elixir(expected);
1883 if field_is_array && expected.is_string() {
1884 let _ = writeln!(
1886 out,
1887 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1888 );
1889 } else {
1890 let _ = writeln!(
1892 out,
1893 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1894 );
1895 }
1896 }
1897 }
1898 "contains_all" => {
1899 if let Some(values) = &assertion.values {
1900 for val in values {
1901 let elixir_val = json_to_elixir(val);
1902 if field_is_array && val.is_string() {
1903 let _ = writeln!(
1904 out,
1905 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1906 );
1907 } else {
1908 let _ = writeln!(
1909 out,
1910 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1911 );
1912 }
1913 }
1914 }
1915 }
1916 "not_contains" => {
1917 if let Some(expected) = &assertion.value {
1918 let elixir_val = json_to_elixir(expected);
1919 if field_is_array && expected.is_string() {
1920 let _ = writeln!(
1921 out,
1922 " refute Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1923 );
1924 } else {
1925 let _ = writeln!(
1926 out,
1927 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1928 );
1929 }
1930 }
1931 }
1932 "not_empty" => {
1933 let _ = writeln!(out, " assert {field_expr} != \"\"");
1934 }
1935 "is_empty" => {
1936 if is_numeric {
1937 let _ = writeln!(out, " assert {field_expr} == 0");
1939 } else {
1940 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1942 }
1943 }
1944 "contains_any" => {
1945 if let Some(values) = &assertion.values {
1946 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1947 let list_str = items.join(", ");
1948 let _ = writeln!(
1949 out,
1950 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1951 );
1952 }
1953 }
1954 "greater_than" => {
1955 if let Some(val) = &assertion.value {
1956 let elixir_val = json_to_elixir(val);
1957 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
1958 }
1959 }
1960 "less_than" => {
1961 if let Some(val) = &assertion.value {
1962 let elixir_val = json_to_elixir(val);
1963 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
1964 }
1965 }
1966 "greater_than_or_equal" => {
1967 if let Some(val) = &assertion.value {
1968 let elixir_val = json_to_elixir(val);
1969 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
1970 }
1971 }
1972 "less_than_or_equal" => {
1973 if let Some(val) = &assertion.value {
1974 let elixir_val = json_to_elixir(val);
1975 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
1976 }
1977 }
1978 "starts_with" => {
1979 if let Some(expected) = &assertion.value {
1980 let elixir_val = json_to_elixir(expected);
1981 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
1982 }
1983 }
1984 "ends_with" => {
1985 if let Some(expected) = &assertion.value {
1986 let elixir_val = json_to_elixir(expected);
1987 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
1988 }
1989 }
1990 "min_length" => {
1991 if let Some(val) = &assertion.value {
1992 if let Some(n) = val.as_u64() {
1993 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1994 }
1995 }
1996 }
1997 "max_length" => {
1998 if let Some(val) = &assertion.value {
1999 if let Some(n) = val.as_u64() {
2000 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
2001 }
2002 }
2003 }
2004 "count_min" => {
2005 if let Some(val) = &assertion.value {
2006 if let Some(n) = val.as_u64() {
2007 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
2008 }
2009 }
2010 }
2011 "count_equals" => {
2012 if let Some(val) = &assertion.value {
2013 if let Some(n) = val.as_u64() {
2014 let _ = writeln!(out, " assert length({field_expr}) == {n}");
2015 }
2016 }
2017 }
2018 "is_true" => {
2019 let _ = writeln!(out, " assert {field_expr} == true");
2020 }
2021 "is_false" => {
2022 let _ = writeln!(out, " assert {field_expr} == false");
2023 }
2024 "method_result" => {
2025 if let Some(method_name) = &assertion.method {
2026 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
2027 let check = assertion.check.as_deref().unwrap_or("is_true");
2028 match check {
2029 "equals" => {
2030 if let Some(val) = &assertion.value {
2031 let elixir_val = json_to_elixir(val);
2032 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
2033 }
2034 }
2035 "is_true" => {
2036 let _ = writeln!(out, " assert {call_expr} == true");
2037 }
2038 "is_false" => {
2039 let _ = writeln!(out, " assert {call_expr} == false");
2040 }
2041 "greater_than_or_equal" => {
2042 if let Some(val) = &assertion.value {
2043 let n = val.as_u64().unwrap_or(0);
2044 let _ = writeln!(out, " assert {call_expr} >= {n}");
2045 }
2046 }
2047 "count_min" => {
2048 if let Some(val) = &assertion.value {
2049 let n = val.as_u64().unwrap_or(0);
2050 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
2051 }
2052 }
2053 "contains" => {
2054 if let Some(val) = &assertion.value {
2055 let elixir_val = json_to_elixir(val);
2056 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
2057 }
2058 }
2059 "is_error" => {
2060 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
2061 }
2062 other_check => {
2063 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
2064 }
2065 }
2066 } else {
2067 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
2068 }
2069 }
2070 "matches_regex" => {
2071 if let Some(expected) = &assertion.value {
2072 let elixir_val = json_to_elixir(expected);
2073 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
2074 }
2075 }
2076 "not_error" => {
2077 }
2079 "error" => {
2080 }
2082 other => {
2083 panic!("Elixir e2e generator: unsupported assertion type: {other}");
2084 }
2085 }
2086}
2087
2088fn build_elixir_method_call(
2091 result_var: &str,
2092 method_name: &str,
2093 args: Option<&serde_json::Value>,
2094 module_path: &str,
2095) -> String {
2096 match method_name {
2097 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
2098 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
2099 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
2100 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
2101 "contains_node_type" => {
2102 let node_type = args
2103 .and_then(|a| a.get("node_type"))
2104 .and_then(|v| v.as_str())
2105 .unwrap_or("");
2106 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
2107 }
2108 "find_nodes_by_type" => {
2109 let node_type = args
2110 .and_then(|a| a.get("node_type"))
2111 .and_then(|v| v.as_str())
2112 .unwrap_or("");
2113 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
2114 }
2115 "run_query" => {
2116 let query_source = args
2117 .and_then(|a| a.get("query_source"))
2118 .and_then(|v| v.as_str())
2119 .unwrap_or("");
2120 let language = args
2121 .and_then(|a| a.get("language"))
2122 .and_then(|v| v.as_str())
2123 .unwrap_or("");
2124 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
2125 }
2126 _ => format!("{module_path}.{method_name}({result_var})"),
2127 }
2128}
2129
2130fn elixir_module_name(category: &str) -> String {
2132 use heck::ToUpperCamelCase;
2133 category.to_upper_camel_case()
2134}
2135
2136fn json_to_elixir(value: &serde_json::Value) -> String {
2138 match value {
2139 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
2140 serde_json::Value::Bool(true) => "true".to_string(),
2141 serde_json::Value::Bool(false) => "false".to_string(),
2142 serde_json::Value::Number(n) => {
2143 let s = n.to_string().replace("e+", "e");
2147 if s.contains('e') && !s.contains('.') {
2148 s.replacen('e', ".0e", 1)
2150 } else {
2151 s
2152 }
2153 }
2154 serde_json::Value::Null => "nil".to_string(),
2155 serde_json::Value::Array(arr) => {
2156 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
2157 format!("[{}]", items.join(", "))
2158 }
2159 serde_json::Value::Object(map) => {
2160 let entries: Vec<String> = map
2161 .iter()
2162 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
2163 .collect();
2164 format!("%{{{}}}", entries.join(", "))
2165 }
2166 }
2167}
2168
2169fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
2171 use std::fmt::Write as FmtWrite;
2172 let mut visitor_obj = String::new();
2173 let _ = writeln!(visitor_obj, "%{{");
2174 for (method_name, action) in &visitor_spec.callbacks {
2175 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
2176 }
2177 let _ = writeln!(visitor_obj, " }}");
2178
2179 setup_lines.push(format!("visitor = {visitor_obj}"));
2180 "visitor".to_string()
2181}
2182
2183fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
2185 use std::fmt::Write as FmtWrite;
2186
2187 let handle_method = format!("handle_{}", &method_name[6..]); let arg_binding = match action {
2197 CallbackAction::CustomTemplate { .. } => "args",
2198 _ => "_args",
2199 };
2200 let _ = writeln!(out, " :{handle_method} => fn({arg_binding}) ->");
2201 match action {
2202 CallbackAction::Skip => {
2203 let _ = writeln!(out, " :skip");
2204 }
2205 CallbackAction::Continue => {
2206 let _ = writeln!(out, " :continue");
2207 }
2208 CallbackAction::PreserveHtml => {
2209 let _ = writeln!(out, " :preserve_html");
2210 }
2211 CallbackAction::Custom { output } => {
2212 let escaped = escape_elixir(output);
2213 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
2214 }
2215 CallbackAction::CustomTemplate { template, .. } => {
2216 let expr = template_to_elixir_concat(template);
2220 let _ = writeln!(out, " {{:custom, {expr}}}");
2221 }
2222 }
2223 let _ = writeln!(out, " end,");
2224}
2225
2226fn template_to_elixir_concat(template: &str) -> String {
2231 let mut parts: Vec<String> = Vec::new();
2232 let mut static_buf = String::new();
2233 let mut chars = template.chars().peekable();
2234
2235 while let Some(ch) = chars.next() {
2236 if ch == '{' {
2237 let mut key = String::new();
2238 let mut closed = false;
2239 for kc in chars.by_ref() {
2240 if kc == '}' {
2241 closed = true;
2242 break;
2243 }
2244 key.push(kc);
2245 }
2246 if closed && !key.is_empty() {
2247 if !static_buf.is_empty() {
2248 let escaped = escape_elixir(&static_buf);
2249 parts.push(format!("\"{escaped}\""));
2250 static_buf.clear();
2251 }
2252 let escaped_key = escape_elixir(&key);
2253 parts.push(format!("Map.get(args, \"{escaped_key}\", \"\")"));
2254 } else {
2255 static_buf.push('{');
2256 static_buf.push_str(&key);
2257 if !closed {
2258 }
2260 }
2261 } else {
2262 static_buf.push(ch);
2263 }
2264 }
2265
2266 if !static_buf.is_empty() {
2267 let escaped = escape_elixir(&static_buf);
2268 parts.push(format!("\"{escaped}\""));
2269 }
2270
2271 if parts.is_empty() {
2272 return "\"\"".to_string();
2273 }
2274 parts.join(" <> ")
2275}
2276
2277fn fixture_has_elixir_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
2278 if fixture.is_http_test() {
2280 return false;
2281 }
2282 let call_config = e2e_config.resolve_call_for_fixture(
2283 fixture.call.as_deref(),
2284 &fixture.id,
2285 &fixture.resolved_category(),
2286 &fixture.tags,
2287 &fixture.input,
2288 );
2289 let elixir_override = call_config
2290 .overrides
2291 .get("elixir")
2292 .or_else(|| e2e_config.call.overrides.get("elixir"));
2293 if elixir_override.and_then(|o| o.client_factory.as_deref()).is_some() {
2295 return true;
2296 }
2297 let function_from_override = elixir_override.and_then(|o| o.function.as_deref());
2302
2303 function_from_override.is_some() || !call_config.function.is_empty()
2305}