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(f.call.as_deref(), &f.input);
87 let elixir_override = cc
88 .overrides
89 .get("elixir")
90 .or_else(|| e2e_config.call.overrides.get("elixir"));
91 elixir_override.and_then(|o| o.client_factory.as_deref()).is_some()
92 })
93 });
94
95 let pkg_ref = e2e_config.resolve_package(lang);
97 let pkg_dep_ref = if has_nif_tests {
98 match e2e_config.dep_mode {
99 crate::config::DependencyMode::Local => pkg_ref
100 .as_ref()
101 .and_then(|p| p.path.as_deref())
102 .unwrap_or("../../packages/elixir")
103 .to_string(),
104 crate::config::DependencyMode::Registry => pkg_ref
105 .as_ref()
106 .and_then(|p| p.version.clone())
107 .or_else(|| config.resolved_version())
108 .unwrap_or_else(|| "0.1.0".to_string()),
109 }
110 } else {
111 String::new()
112 };
113
114 let pkg_atom = config.elixir_app_name();
123 files.push(GeneratedFile {
124 path: output_base.join("mix.exs"),
125 content: render_mix_exs(
126 &pkg_atom,
127 &pkg_dep_ref,
128 e2e_config.dep_mode,
129 has_http_tests,
130 has_nif_tests,
131 ),
132 generated_header: false,
133 });
134
135 files.push(GeneratedFile {
137 path: output_base.join("lib").join("e2e_elixir.ex"),
138 content: "defmodule E2eElixir do\n @moduledoc false\nend\n".to_string(),
139 generated_header: false,
140 });
141
142 files.push(GeneratedFile {
144 path: output_base.join("test").join("test_helper.exs"),
145 content: render_test_helper(has_http_tests || has_mock_server_tests),
146 generated_header: false,
147 });
148
149 for group in groups {
151 let active: Vec<&Fixture> = group
152 .fixtures
153 .iter()
154 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
155 .collect();
156
157 if active.is_empty() {
158 continue;
159 }
160
161 let filename = format!("{}_test.exs", sanitize_filename(&group.category));
162 let field_resolver = FieldResolver::new(
163 &e2e_config.fields,
164 &e2e_config.fields_optional,
165 &e2e_config.result_fields,
166 &e2e_config.fields_array,
167 &std::collections::HashSet::new(),
168 );
169 let content = render_test_file(
170 &group.category,
171 &active,
172 e2e_config,
173 &module_path,
174 &function_name,
175 result_var,
176 &e2e_config.call.args,
177 &field_resolver,
178 options_type.as_deref(),
179 options_default_fn.as_deref(),
180 enum_fields,
181 handle_struct_type.as_deref(),
182 handle_atom_list_fields,
183 );
184 files.push(GeneratedFile {
185 path: output_base.join("test").join(filename),
186 content,
187 generated_header: true,
188 });
189 }
190
191 Ok(files)
192 }
193
194 fn language_name(&self) -> &'static str {
195 "elixir"
196 }
197}
198
199fn render_test_helper(has_http_tests: bool) -> String {
200 if has_http_tests {
201 r#"ExUnit.start()
202
203# Spawn mock-server binary and set MOCK_SERVER_URL for all tests.
204mock_server_bin = Path.expand("../../rust/target/release/mock-server", __DIR__)
205fixtures_dir = Path.expand("../../../fixtures", __DIR__)
206
207if File.exists?(mock_server_bin) do
208 port = Port.open({:spawn_executable, mock_server_bin}, [
209 :binary,
210 # Use a large line buffer (default 1024 truncates `MOCK_SERVERS={...}` lines for
211 # fixture sets with many host-root routes, splitting them into `:noeol` chunks
212 # that the prefix-match clauses below would never see).
213 {:line, 65_536},
214 args: [fixtures_dir]
215 ])
216 # Read startup lines: MOCK_SERVER_URL= then MOCK_SERVERS= (always emitted, possibly `{}`).
217 # The standalone mock-server prints noisy stderr lines BEFORE the stdout sentinels;
218 # selective receive ignores anything that doesn't match the two prefix patterns.
219 # Each iteration only halts after the MOCK_SERVERS= line is processed.
220 {url, _} =
221 Enum.reduce_while(1..16, {nil, port}, fn _, {url_acc, p} ->
222 receive do
223 {^p, {:data, {:eol, "MOCK_SERVER_URL=" <> u}}} ->
224 {:cont, {u, p}}
225
226 {^p, {:data, {:eol, "MOCK_SERVERS=" <> json_val}}} ->
227 System.put_env("MOCK_SERVERS", json_val)
228 case Jason.decode(json_val) do
229 {:ok, servers} ->
230 Enum.each(servers, fn {fid, furl} ->
231 System.put_env("MOCK_SERVER_#{String.upcase(fid)}", furl)
232 end)
233
234 _ ->
235 :ok
236 end
237
238 {:halt, {url_acc, p}}
239 after
240 30_000 ->
241 raise "mock-server startup timeout"
242 end
243 end)
244
245 if url != nil do
246 System.put_env("MOCK_SERVER_URL", url)
247 end
248end
249"#
250 .to_string()
251 } else {
252 "ExUnit.start()\n".to_string()
253 }
254}
255
256fn render_mix_exs(
257 pkg_name: &str,
258 pkg_path: &str,
259 dep_mode: crate::config::DependencyMode,
260 has_http_tests: bool,
261 has_nif_tests: bool,
262) -> String {
263 let mut out = String::new();
264 let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
265 let _ = writeln!(out, " use Mix.Project");
266 let _ = writeln!(out);
267 let _ = writeln!(out, " def project do");
268 let _ = writeln!(out, " [");
269 let _ = writeln!(out, " app: :e2e_elixir,");
270 let _ = writeln!(out, " version: \"0.1.0\",");
271 let _ = writeln!(out, " elixir: \"~> 1.14\",");
272 let _ = writeln!(out, " deps: deps()");
273 let _ = writeln!(out, " ]");
274 let _ = writeln!(out, " end");
275 let _ = writeln!(out);
276 let _ = writeln!(out, " defp deps do");
277 let _ = writeln!(out, " [");
278
279 let mut deps: Vec<String> = Vec::new();
281
282 if has_nif_tests && !pkg_path.is_empty() {
284 let pkg_atom = pkg_name;
285 let nif_dep = match dep_mode {
286 crate::config::DependencyMode::Local => {
287 format!(" {{:{pkg_atom}, path: \"{pkg_path}\"}}")
288 }
289 crate::config::DependencyMode::Registry => {
290 format!(" {{:{pkg_atom}, \"{pkg_path}\"}}")
292 }
293 };
294 deps.push(nif_dep);
295 deps.push(format!(
297 " {{:rustler_precompiled, \"{rp}\"}}",
298 rp = tv::hex::RUSTLER_PRECOMPILED
299 ));
300 deps.push(format!(
305 " {{:rustler, \"{rustler}\", runtime: false}}",
306 rustler = tv::hex::RUSTLER
307 ));
308 }
309
310 if has_http_tests {
312 deps.push(format!(" {{:req, \"{req}\"}}", req = tv::hex::REQ));
313 deps.push(format!(" {{:jason, \"{jason}\"}}", jason = tv::hex::JASON));
314 }
315
316 let _ = writeln!(out, "{}", deps.join(",\n"));
317 let _ = writeln!(out, " ]");
318 let _ = writeln!(out, " end");
319 let _ = writeln!(out, "end");
320 out
321}
322
323#[allow(clippy::too_many_arguments)]
324fn render_test_file(
325 category: &str,
326 fixtures: &[&Fixture],
327 e2e_config: &E2eConfig,
328 module_path: &str,
329 function_name: &str,
330 result_var: &str,
331 args: &[crate::config::ArgMapping],
332 field_resolver: &FieldResolver,
333 options_type: Option<&str>,
334 options_default_fn: Option<&str>,
335 enum_fields: &HashMap<String, String>,
336 handle_struct_type: Option<&str>,
337 handle_atom_list_fields: &std::collections::HashSet<String>,
338) -> String {
339 let mut out = String::new();
340 out.push_str(&hash::header(CommentStyle::Hash));
341 let _ = writeln!(out, "# E2e tests for category: {category}");
342 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
343
344 let has_http = fixtures.iter().any(|f| f.is_http_test());
346
347 let async_flag = if has_http { "true" } else { "false" };
350 let _ = writeln!(out, " use ExUnit.Case, async: {async_flag}");
351
352 if has_http {
353 let _ = writeln!(out);
354 let _ = writeln!(out, " defp mock_server_url do");
355 let _ = writeln!(
356 out,
357 " System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
358 );
359 let _ = writeln!(out, " end");
360 }
361
362 let has_array_contains = fixtures.iter().any(|fixture| {
365 fixture.assertions.iter().any(|a| {
366 matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
367 && a.field
368 .as_deref()
369 .is_some_and(|f| !f.is_empty() && field_resolver.is_array(field_resolver.resolve(f)))
370 })
371 });
372 if has_array_contains {
373 let _ = writeln!(out);
374 let _ = writeln!(out, " defp alef_e2e_item_texts(item) when is_binary(item), do: [item]");
375 let _ = writeln!(out, " defp alef_e2e_item_texts(item) do");
376 let _ = writeln!(out, " [:kind, :name, :signature, :path, :alias, :text, :source]");
377 let _ = writeln!(out, " |> Enum.filter(&Map.has_key?(item, &1))");
378 let _ = writeln!(out, " |> Enum.flat_map(fn attr ->");
379 let _ = writeln!(out, " case Map.get(item, attr) do");
380 let _ = writeln!(out, " nil -> []");
381 let _ = writeln!(
382 out,
383 " atom when is_atom(atom) -> [atom |> to_string() |> String.capitalize()]"
384 );
385 let _ = writeln!(out, " str -> [to_string(str)]");
386 let _ = writeln!(out, " end");
387 let _ = writeln!(out, " end)");
388 let _ = writeln!(out, " end");
389 }
390
391 let _ = writeln!(out);
392
393 for (i, fixture) in fixtures.iter().enumerate() {
394 if let Some(http) = &fixture.http {
395 render_http_test_case(&mut out, fixture, http);
396 } else {
397 render_test_case(
398 &mut out,
399 fixture,
400 e2e_config,
401 module_path,
402 function_name,
403 result_var,
404 args,
405 field_resolver,
406 options_type,
407 options_default_fn,
408 enum_fields,
409 handle_struct_type,
410 handle_atom_list_fields,
411 );
412 }
413 if i + 1 < fixtures.len() {
414 let _ = writeln!(out);
415 }
416 }
417
418 let _ = writeln!(out, "end");
419 out
420}
421
422const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
429
430const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
433
434struct ElixirTestClientRenderer<'a> {
438 fixture_id: &'a str,
441 expected_status: u16,
443}
444
445impl<'a> client::TestClientRenderer for ElixirTestClientRenderer<'a> {
446 fn language_name(&self) -> &'static str {
447 "elixir"
448 }
449
450 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
456 let escaped_description = description.replace('"', "\\\"");
457 let _ = writeln!(out, " describe \"{fn_name}\" do");
458 if skip_reason.is_some() {
459 let _ = writeln!(out, " @tag :skip");
460 }
461 let _ = writeln!(out, " test \"{escaped_description}\" do");
462 }
463
464 fn render_test_close(&self, out: &mut String) {
466 let _ = writeln!(out, " end");
467 let _ = writeln!(out, " end");
468 }
469
470 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
472 let method = ctx.method.to_lowercase();
473 let mut opts: Vec<String> = Vec::new();
474
475 if let Some(body) = ctx.body {
476 let elixir_val = json_to_elixir(body);
477 opts.push(format!("json: {elixir_val}"));
478 }
479
480 if !ctx.headers.is_empty() {
481 let header_pairs: Vec<String> = ctx
482 .headers
483 .iter()
484 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
485 .collect();
486 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
487 }
488
489 if !ctx.cookies.is_empty() {
490 let cookie_str = ctx
491 .cookies
492 .iter()
493 .map(|(k, v)| format!("{k}={v}"))
494 .collect::<Vec<_>>()
495 .join("; ");
496 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
497 }
498
499 if !ctx.query_params.is_empty() {
500 let pairs: Vec<String> = ctx
501 .query_params
502 .iter()
503 .map(|(k, v)| {
504 let val_str = match v {
505 serde_json::Value::String(s) => s.clone(),
506 other => other.to_string(),
507 };
508 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
509 })
510 .collect();
511 opts.push(format!("params: [{}]", pairs.join(", ")));
512 }
513
514 if (300..400).contains(&self.expected_status) {
517 opts.push("redirect: false".to_string());
518 }
519
520 let fixture_id = escape_elixir(self.fixture_id);
521 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{fixture_id}\"");
522
523 if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
524 if opts.is_empty() {
525 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
526 } else {
527 let opts_str = opts.join(", ");
528 let _ = writeln!(
529 out,
530 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
531 );
532 }
533 } else {
534 opts.insert(0, format!("method: :{method}"));
535 opts.insert(1, format!("url: {url_expr}"));
536 let opts_str = opts.join(", ");
537 let _ = writeln!(out, " {{:ok, response}} = Req.request({opts_str})");
538 }
539 }
540
541 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
542 let _ = writeln!(out, " assert {response_var}.status == {status}");
543 }
544
545 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
550 let header_key = name.to_lowercase();
551 if header_key == "connection" {
553 return;
554 }
555 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
556 let get_header_expr = format!(
557 "Enum.find_value({response_var}.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
558 );
559 match expected {
560 "<<present>>" => {
561 let _ = writeln!(out, " assert {get_header_expr} != nil");
562 }
563 "<<absent>>" => {
564 let _ = writeln!(out, " assert {get_header_expr} == nil");
565 }
566 "<<uuid>>" => {
567 let var = sanitize_ident(&header_key);
568 let _ = writeln!(out, " header_val_{var} = {get_header_expr}");
569 let _ = writeln!(
570 out,
571 " 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}))"
572 );
573 }
574 literal => {
575 let val_lit = format!("\"{}\"", escape_elixir(literal));
576 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
577 }
578 }
579 }
580
581 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
586 let elixir_val = json_to_elixir(expected);
587 match expected {
588 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
589 let _ = writeln!(
590 out,
591 " body_decoded = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
592 );
593 let _ = writeln!(out, " assert body_decoded == {elixir_val}");
594 }
595 _ => {
596 let _ = writeln!(out, " assert {response_var}.body == {elixir_val}");
597 }
598 }
599 }
600
601 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
603 if let Some(obj) = expected.as_object() {
604 let _ = writeln!(
605 out,
606 " decoded_body = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
607 );
608 for (key, val) in obj {
609 let key_lit = format!("\"{}\"", escape_elixir(key));
610 let elixir_val = json_to_elixir(val);
611 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
612 }
613 }
614 }
615
616 fn render_assert_validation_errors(
619 &self,
620 out: &mut String,
621 response_var: &str,
622 errors: &[ValidationErrorExpectation],
623 ) {
624 for err in errors {
625 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
626 let _ = writeln!(
627 out,
628 " assert String.contains?(Jason.encode!({response_var}.body), {msg_lit})"
629 );
630 }
631 }
632}
633
634fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
640 let method = http.request.method.to_uppercase();
641
642 if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
646 let test_name = sanitize_ident(&fixture.id);
647 let test_label = fixture.id.replace('"', "\\\"");
648 let path = &http.request.path;
649 let _ = writeln!(out, " describe \"{test_name}\" do");
650 let _ = writeln!(out, " @tag :skip");
651 let _ = writeln!(out, " test \"{method} {path} - {test_label}\" do");
652 let _ = writeln!(out, " end");
653 let _ = writeln!(out, " end");
654 return;
655 }
656
657 let renderer = ElixirTestClientRenderer {
658 fixture_id: &fixture.id,
659 expected_status: http.expected_response.status_code,
660 };
661 client::http_call::render_http_test(out, &renderer, fixture);
662}
663
664#[allow(clippy::too_many_arguments)]
669fn render_test_case(
670 out: &mut String,
671 fixture: &Fixture,
672 e2e_config: &E2eConfig,
673 default_module_path: &str,
674 default_function_name: &str,
675 default_result_var: &str,
676 args: &[crate::config::ArgMapping],
677 field_resolver: &FieldResolver,
678 options_type: Option<&str>,
679 options_default_fn: Option<&str>,
680 enum_fields: &HashMap<String, String>,
681 handle_struct_type: Option<&str>,
682 handle_atom_list_fields: &std::collections::HashSet<String>,
683) {
684 let test_name = sanitize_ident(&fixture.id);
685 let test_label = fixture.id.replace('"', "\\\"");
686
687 if fixture.mock_response.is_none() && !fixture_has_elixir_callable(fixture, e2e_config) {
693 let _ = writeln!(out, " describe \"{test_name}\" do");
694 let _ = writeln!(out, " @tag :skip");
695 let _ = writeln!(out, " test \"{test_label}\" do");
696 let _ = writeln!(
697 out,
698 " # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
699 );
700 let _ = writeln!(out, " :ok");
701 let _ = writeln!(out, " end");
702 let _ = writeln!(out, " end");
703 return;
704 }
705
706 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
708 let lang = "elixir";
709 let call_overrides = call_config.overrides.get(lang);
710
711 let base_fn = call_overrides
714 .and_then(|o| o.function.as_ref())
715 .cloned()
716 .unwrap_or_else(|| call_config.function.clone());
717 if base_fn.starts_with("batch_extract_") {
718 let _ = writeln!(
719 out,
720 " describe \"{test_name}\" do",
721 test_name = sanitize_ident(&fixture.id)
722 );
723 let _ = writeln!(out, " @tag :skip");
724 let _ = writeln!(
725 out,
726 " test \"{test_label}\" do",
727 test_label = fixture.id.replace('"', "\\\"")
728 );
729 let _ = writeln!(
730 out,
731 " # batch functions excluded from Elixir binding: unsafe NIF tuple marshalling"
732 );
733 let _ = writeln!(out, " :ok");
734 let _ = writeln!(out, " end");
735 let _ = writeln!(out, " end");
736 return;
737 }
738
739 let (module_path, function_name, result_var) = if fixture.call.is_some() {
742 let raw_module = call_overrides
743 .and_then(|o| o.module.as_ref())
744 .cloned()
745 .unwrap_or_else(|| call_config.module.clone());
746 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
747 {
748 raw_module.clone()
749 } else {
750 elixir_module_name(&raw_module)
751 };
752 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") && !base_fn.ends_with("_stream") {
753 format!("{base_fn}_async")
754 } else {
755 base_fn
756 };
757 (resolved_module, resolved_fn, call_config.result_var.clone())
758 } else {
759 (
760 default_module_path.to_string(),
761 default_function_name.to_string(),
762 default_result_var.to_string(),
763 )
764 };
765
766 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
767 let validation_creation_failure = expects_error && fixture.resolved_category() == "validation";
771
772 let (
774 effective_args,
775 effective_options_type,
776 effective_options_default_fn,
777 effective_enum_fields,
778 effective_handle_struct_type,
779 effective_handle_atom_list_fields,
780 );
781 let empty_enum_fields_local: HashMap<String, String>;
782 let empty_atom_fields_local: std::collections::HashSet<String>;
783 let (
784 resolved_args,
785 resolved_options_type,
786 resolved_options_default_fn,
787 resolved_enum_fields_ref,
788 resolved_handle_struct_type,
789 resolved_handle_atom_list_fields_ref,
790 ) = if fixture.call.is_some() {
791 let co = call_config.overrides.get(lang);
792 effective_args = call_config.args.as_slice();
793 effective_options_type = co.and_then(|o| o.options_type.as_deref());
794 effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
795 empty_enum_fields_local = HashMap::new();
796 effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
797 effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
798 empty_atom_fields_local = std::collections::HashSet::new();
799 effective_handle_atom_list_fields = co
800 .map(|o| &o.handle_atom_list_fields)
801 .unwrap_or(&empty_atom_fields_local);
802 (
803 effective_args,
804 effective_options_type,
805 effective_options_default_fn,
806 effective_enum_fields,
807 effective_handle_struct_type,
808 effective_handle_atom_list_fields,
809 )
810 } else {
811 (
812 args as &[_],
813 options_type,
814 options_default_fn,
815 enum_fields,
816 handle_struct_type,
817 handle_atom_list_fields,
818 )
819 };
820
821 let test_documents_path = e2e_config.test_documents_relative_from(0);
822 let (mut setup_lines, args_str) = build_args_and_setup(
823 &fixture.input,
824 resolved_args,
825 &module_path,
826 resolved_options_type,
827 resolved_options_default_fn,
828 resolved_enum_fields_ref,
829 fixture,
830 resolved_handle_struct_type,
831 resolved_handle_atom_list_fields_ref,
832 &test_documents_path,
833 );
834
835 let visitor_var = fixture
837 .visitor
838 .as_ref()
839 .map(|visitor_spec| build_elixir_visitor(&mut setup_lines, visitor_spec));
840
841 let final_args = if let Some(ref visitor_var) = visitor_var {
844 let parts: Vec<&str> = args_str.split(", ").collect();
848 if parts.len() == 2 && parts[1] == "nil" {
849 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
851 } else if parts.len() == 2 {
852 setup_lines.push(format!(
854 "{} = Map.put({}, :visitor, {})",
855 parts[1], parts[1], visitor_var
856 ));
857 args_str
858 } else if parts.len() == 1 {
859 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
861 } else {
862 args_str
863 }
864 } else {
865 args_str
866 };
867
868 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
870 e2e_config
871 .call
872 .overrides
873 .get("elixir")
874 .and_then(|o| o.client_factory.as_deref())
875 });
876
877 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
881 let final_args_with_extras = if extra_args.is_empty() {
882 final_args
883 } else if final_args.is_empty() {
884 extra_args.join(", ")
885 } else {
886 format!("{final_args}, {}", extra_args.join(", "))
887 };
888
889 let effective_args = if client_factory.is_some() {
891 if final_args_with_extras.is_empty() {
892 "client".to_string()
893 } else {
894 format!("client, {final_args_with_extras}")
895 }
896 } else {
897 final_args_with_extras
898 };
899
900 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
904 let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
905 let needs_api_key_skip = !has_mock && api_key_var_opt.is_some();
906 let needs_env_fallback = has_mock && api_key_var_opt.is_some();
909
910 let _ = writeln!(out, " describe \"{test_name}\" do");
911 let _ = writeln!(out, " test \"{test_label}\" do");
912
913 if needs_api_key_skip {
914 let api_key_var = api_key_var_opt.unwrap_or("");
915 let _ = writeln!(out, " if System.get_env(\"{api_key_var}\") in [nil, \"\"] do");
916 let _ = writeln!(out, " # {api_key_var} not set — skipping live smoke test");
917 let _ = writeln!(out, " :ok");
918 let _ = writeln!(out, " else");
919 }
920
921 if validation_creation_failure {
925 let mut emitted_error_assertion = false;
926 for line in &setup_lines {
927 if !emitted_error_assertion && line.starts_with("{:ok,") {
928 if let Some(rhs) = line.split_once('=').map(|x| x.1) {
929 let rhs = rhs.trim();
930 let _ = writeln!(out, " assert {{:error, _}} = {rhs}");
931 emitted_error_assertion = true;
932 } else {
933 let _ = writeln!(out, " {line}");
934 }
935 } else {
936 let _ = writeln!(out, " {line}");
937 }
938 }
939 if !emitted_error_assertion {
940 let _ = writeln!(
941 out,
942 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
943 );
944 }
945 if needs_api_key_skip {
946 let _ = writeln!(out, " end");
947 }
948 let _ = writeln!(out, " end");
949 let _ = writeln!(out, " end");
950 return;
951 }
952
953 if expects_error {
960 for line in &setup_lines {
961 let _ = writeln!(out, " {line}");
962 }
963 if let Some(factory) = client_factory {
964 let fixture_id = &fixture.id;
965 let base_url_expr = if fixture.has_host_root_route() {
966 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
967 format!(
968 "(System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
969 )
970 } else {
971 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
972 };
973 let _ = writeln!(
974 out,
975 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", base_url: {base_url_expr})"
976 );
977 }
978 let _ = writeln!(
979 out,
980 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
981 );
982 if needs_api_key_skip {
983 let _ = writeln!(out, " end");
984 }
985 let _ = writeln!(out, " end");
986 let _ = writeln!(out, " end");
987 return;
988 }
989
990 for line in &setup_lines {
991 let _ = writeln!(out, " {line}");
992 }
993
994 if let Some(factory) = client_factory {
996 let fixture_id = &fixture.id;
997 if needs_env_fallback {
998 let api_key_var = api_key_var_opt.unwrap_or("");
1001 let mock_url_expr = if fixture.has_host_root_route() {
1002 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1003 format!(
1004 "System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\""
1005 )
1006 } else {
1007 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
1008 };
1009 let _ = writeln!(out, " api_key_val = System.get_env(\"{api_key_var}\")");
1010 let _ = writeln!(
1011 out,
1012 " {{api_key_val, client_opts}} = if api_key_val && api_key_val != \"\" do"
1013 );
1014 let _ = writeln!(
1015 out,
1016 " IO.puts(\"{fixture_id}: using real API ({api_key_var} is set)\")"
1017 );
1018 let _ = writeln!(out, " {{api_key_val, []}}");
1019 let _ = writeln!(out, " else");
1020 let _ = writeln!(
1021 out,
1022 " IO.puts(\"{fixture_id}: using mock server ({api_key_var} not set)\")"
1023 );
1024 let _ = writeln!(out, " {{\"test-key\", [base_url: {mock_url_expr}]}}");
1025 let _ = writeln!(out, " end");
1026 let _ = writeln!(
1027 out,
1028 " {{:ok, client}} = {module_path}.{factory}(api_key_val, client_opts)"
1029 );
1030 } else {
1031 let base_url_expr = if fixture.has_host_root_route() {
1032 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1033 format!(
1034 "(System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
1035 )
1036 } else {
1037 format!("(System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"")
1038 };
1039 let _ = writeln!(
1040 out,
1041 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", base_url: {base_url_expr})"
1042 );
1043 }
1044 }
1045
1046 let returns_result = call_overrides
1048 .and_then(|o| o.returns_result)
1049 .unwrap_or(call_config.returns_result || client_factory.is_some());
1050
1051 let result_is_simple = call_config.result_is_simple || call_overrides.is_some_and(|o| o.result_is_simple);
1056
1057 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1059 let chunks_var = "chunks";
1061
1062 if returns_result {
1063 let _ = writeln!(
1064 out,
1065 " {{:ok, {result_var}}} = {module_path}.{function_name}({effective_args})"
1066 );
1067 } else {
1068 let _ = writeln!(
1070 out,
1071 " {result_var} = {module_path}.{function_name}({effective_args})"
1072 );
1073 }
1074
1075 if is_streaming {
1077 if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
1078 "elixir",
1079 &result_var,
1080 chunks_var,
1081 ) {
1082 let _ = writeln!(out, " {collect}");
1083 }
1084 }
1085
1086 for assertion in &fixture.assertions {
1087 render_assertion(
1088 out,
1089 assertion,
1090 if is_streaming { chunks_var } else { &result_var },
1091 field_resolver,
1092 &module_path,
1093 &e2e_config.fields_enum,
1094 resolved_enum_fields_ref,
1095 result_is_simple,
1096 is_streaming,
1097 );
1098 }
1099
1100 if needs_api_key_skip {
1101 let _ = writeln!(out, " end");
1102 }
1103 let _ = writeln!(out, " end");
1104 let _ = writeln!(out, " end");
1105}
1106
1107#[allow(clippy::too_many_arguments)]
1111fn emit_elixir_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1113 if let Some(items) = arr.as_array() {
1114 let item_strs: Vec<String> = items
1115 .iter()
1116 .filter_map(|item| {
1117 if let Some(obj) = item.as_object() {
1118 match elem_type {
1119 "BatchBytesItem" => {
1120 let content = obj.get("content").and_then(|v| v.as_array());
1121 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1122 let content_code = if let Some(arr) = content {
1123 let bytes: Vec<String> =
1124 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1125 format!("<<{}>>", bytes.join(", "))
1126 } else {
1127 "<<>>".to_string()
1128 };
1129 Some(format!(
1130 "%BatchBytesItem{{content: {}, mime_type: \"{}\"}}",
1131 content_code, mime_type
1132 ))
1133 }
1134 "BatchFileItem" => {
1135 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1136 Some(format!("%BatchFileItem{{path: \"{}\"}}", path))
1137 }
1138 _ => None,
1139 }
1140 } else {
1141 None
1142 }
1143 })
1144 .collect();
1145 format!("[{}]", item_strs.join(", "))
1146 } else {
1147 "[]".to_string()
1148 }
1149}
1150
1151#[allow(clippy::too_many_arguments)]
1152fn build_args_and_setup(
1153 input: &serde_json::Value,
1154 args: &[crate::config::ArgMapping],
1155 module_path: &str,
1156 options_type: Option<&str>,
1157 options_default_fn: Option<&str>,
1158 enum_fields: &HashMap<String, String>,
1159 fixture: &crate::fixture::Fixture,
1160 _handle_struct_type: Option<&str>,
1161 _handle_atom_list_fields: &std::collections::HashSet<String>,
1162 test_documents_path: &str,
1163) -> (Vec<String>, String) {
1164 let fixture_id = &fixture.id;
1165 if args.is_empty() {
1166 let is_empty_input = match input {
1170 serde_json::Value::Null => true,
1171 serde_json::Value::Object(m) => m.is_empty(),
1172 _ => false,
1173 };
1174 if is_empty_input {
1175 return (Vec::new(), String::new());
1176 }
1177 return (Vec::new(), json_to_elixir(input));
1178 }
1179
1180 let mut setup_lines: Vec<String> = Vec::new();
1181 let mut parts: Vec<String> = Vec::new();
1182
1183 for arg in args {
1184 if arg.arg_type == "mock_url" {
1185 if fixture.has_host_root_route() {
1186 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1187 setup_lines.push(format!(
1188 "{} = System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1189 arg.name,
1190 ));
1191 } else {
1192 setup_lines.push(format!(
1193 "{} = (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1194 arg.name,
1195 ));
1196 }
1197 parts.push(arg.name.clone());
1198 continue;
1199 }
1200
1201 if arg.arg_type == "handle" {
1202 let constructor_name = format!("create_{}", arg.name.to_snake_case());
1206 let config_value = if arg.field == "input" {
1207 input
1208 } else {
1209 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1210 input.get(field).unwrap_or(&serde_json::Value::Null)
1211 };
1212 let name = &arg.name;
1213 if config_value.is_null()
1214 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1215 {
1216 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
1217 } else {
1218 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
1221 let escaped = escape_elixir(&json_str);
1222 setup_lines.push(format!("{name}_config = \"{escaped}\""));
1223 setup_lines.push(format!(
1224 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
1225 ));
1226 }
1227 parts.push(arg.name.clone());
1228 continue;
1229 }
1230
1231 let val = if arg.field == "input" {
1232 Some(input)
1233 } else {
1234 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1235 input.get(field)
1236 };
1237 match val {
1238 None | Some(serde_json::Value::Null) if arg.optional => {
1239 continue;
1242 }
1243 None | Some(serde_json::Value::Null) => {
1244 let default_val = match arg.arg_type.as_str() {
1246 "string" => "\"\"".to_string(),
1247 "int" | "integer" => "0".to_string(),
1248 "float" | "number" => "0.0".to_string(),
1249 "bool" | "boolean" => "false".to_string(),
1250 _ => "nil".to_string(),
1251 };
1252 parts.push(default_val);
1253 }
1254 Some(v) => {
1255 if arg.arg_type == "file_path" {
1258 if let Some(path_str) = v.as_str() {
1259 let full_path = format!("{test_documents_path}/{path_str}");
1260 parts.push(format!("\"{}\"", escape_elixir(&full_path)));
1261 continue;
1262 }
1263 }
1264 if arg.arg_type == "bytes" {
1267 if let Some(raw) = v.as_str() {
1268 let var_name = &arg.name;
1269 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
1270 parts.push(format!("\"{}\"", escape_elixir(raw)));
1272 } else {
1273 let first = raw.chars().next().unwrap_or('\0');
1274 let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
1275 && raw
1276 .find('/')
1277 .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
1278 if is_file_path {
1279 let full_path = format!("{test_documents_path}/{raw}");
1282 let escaped = escape_elixir(&full_path);
1283 setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
1284 parts.push(var_name.to_string());
1285 } else {
1286 setup_lines.push(format!(
1288 "{var_name} = Base.decode64!(\"{}\", padding: false)",
1289 escape_elixir(raw)
1290 ));
1291 parts.push(var_name.to_string());
1292 }
1293 }
1294 continue;
1295 }
1296 }
1297 if arg.arg_type == "json_object" && !v.is_null() {
1299 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
1300 (options_type, options_default_fn, v.as_object())
1301 {
1302 let options_var = "options";
1304 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
1305
1306 for (k, vv) in obj.iter() {
1308 let snake_key = k.to_snake_case();
1309 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1310 if let Some(s) = vv.as_str() {
1311 let snake_val = s.to_snake_case();
1312 format!(":{snake_val}")
1314 } else {
1315 json_to_elixir(vv)
1316 }
1317 } else {
1318 json_to_elixir(vv)
1319 };
1320 setup_lines.push(format!(
1321 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
1322 ));
1323 }
1324
1325 parts.push(options_var.to_string());
1327 continue;
1328 }
1329 if let Some(elem_type) = &arg.element_type {
1331 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1332 parts.push(emit_elixir_batch_item_array(v, elem_type));
1333 continue;
1334 }
1335 if v.is_array() {
1338 parts.push(json_to_elixir(v));
1339 continue;
1340 }
1341 }
1342 if !v.is_null() {
1346 let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
1347 let escaped = escape_elixir(&json_str);
1348 parts.push(format!("\"{escaped}\""));
1349 continue;
1350 }
1351 }
1352 let elixir_val = json_to_elixir(v);
1354 if arg.optional {
1355 parts.push(format!("{}: {elixir_val}", arg.name));
1356 } else {
1357 parts.push(elixir_val);
1358 }
1359 }
1360 }
1361 }
1362
1363 (setup_lines, parts.join(", "))
1364}
1365
1366fn is_numeric_expr(field_expr: &str) -> bool {
1369 field_expr.starts_with("length(")
1370}
1371
1372#[allow(clippy::too_many_arguments)]
1373fn render_assertion(
1374 out: &mut String,
1375 assertion: &Assertion,
1376 result_var: &str,
1377 field_resolver: &FieldResolver,
1378 module_path: &str,
1379 fields_enum: &std::collections::HashSet<String>,
1380 per_call_enum_fields: &HashMap<String, String>,
1381 result_is_simple: bool,
1382 is_streaming: bool,
1383) {
1384 if let Some(f) = &assertion.field {
1387 match f.as_str() {
1388 "chunks_have_content" => {
1389 let pred =
1390 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
1391 match assertion.assertion_type.as_str() {
1392 "is_true" => {
1393 let _ = writeln!(out, " assert {pred}");
1394 }
1395 "is_false" => {
1396 let _ = writeln!(out, " refute {pred}");
1397 }
1398 _ => {
1399 let _ = writeln!(
1400 out,
1401 " # skipped: unsupported assertion type on synthetic field '{f}'"
1402 );
1403 }
1404 }
1405 return;
1406 }
1407 "chunks_have_embeddings" => {
1408 let pred = format!(
1409 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
1410 );
1411 match assertion.assertion_type.as_str() {
1412 "is_true" => {
1413 let _ = writeln!(out, " assert {pred}");
1414 }
1415 "is_false" => {
1416 let _ = writeln!(out, " refute {pred}");
1417 }
1418 _ => {
1419 let _ = writeln!(
1420 out,
1421 " # skipped: unsupported assertion type on synthetic field '{f}'"
1422 );
1423 }
1424 }
1425 return;
1426 }
1427 "embeddings" => {
1431 match assertion.assertion_type.as_str() {
1432 "count_equals" => {
1433 if let Some(val) = &assertion.value {
1434 let ex_val = json_to_elixir(val);
1435 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
1436 }
1437 }
1438 "count_min" => {
1439 if let Some(val) = &assertion.value {
1440 let ex_val = json_to_elixir(val);
1441 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
1442 }
1443 }
1444 "not_empty" => {
1445 let _ = writeln!(out, " assert {result_var} != []");
1446 }
1447 "is_empty" => {
1448 let _ = writeln!(out, " assert {result_var} == []");
1449 }
1450 _ => {
1451 let _ = writeln!(
1452 out,
1453 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1454 );
1455 }
1456 }
1457 return;
1458 }
1459 "embedding_dimensions" => {
1460 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1461 match assertion.assertion_type.as_str() {
1462 "equals" => {
1463 if let Some(val) = &assertion.value {
1464 let ex_val = json_to_elixir(val);
1465 let _ = writeln!(out, " assert {expr} == {ex_val}");
1466 }
1467 }
1468 "greater_than" => {
1469 if let Some(val) = &assertion.value {
1470 let ex_val = json_to_elixir(val);
1471 let _ = writeln!(out, " assert {expr} > {ex_val}");
1472 }
1473 }
1474 _ => {
1475 let _ = writeln!(
1476 out,
1477 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1478 );
1479 }
1480 }
1481 return;
1482 }
1483 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1484 let pred = match f.as_str() {
1485 "embeddings_valid" => {
1486 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1487 }
1488 "embeddings_finite" => {
1489 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1490 }
1491 "embeddings_non_zero" => {
1492 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1493 }
1494 "embeddings_normalized" => {
1495 format!(
1496 "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)"
1497 )
1498 }
1499 _ => unreachable!(),
1500 };
1501 match assertion.assertion_type.as_str() {
1502 "is_true" => {
1503 let _ = writeln!(out, " assert {pred}");
1504 }
1505 "is_false" => {
1506 let _ = writeln!(out, " refute {pred}");
1507 }
1508 _ => {
1509 let _ = writeln!(
1510 out,
1511 " # skipped: unsupported assertion type on synthetic field '{f}'"
1512 );
1513 }
1514 }
1515 return;
1516 }
1517 "keywords" | "keywords_count" => {
1520 let _ = writeln!(
1521 out,
1522 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1523 );
1524 return;
1525 }
1526 _ => {}
1527 }
1528 }
1529
1530 if is_streaming {
1533 if let Some(f) = &assertion.field {
1534 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1535 if let Some(expr) =
1536 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "elixir", result_var)
1537 {
1538 match assertion.assertion_type.as_str() {
1539 "count_min" => {
1540 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1541 let _ = writeln!(out, " assert length({expr}) >= {n}");
1542 }
1543 }
1544 "count_equals" => {
1545 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1546 let _ = writeln!(out, " assert length({expr}) == {n}");
1547 }
1548 }
1549 "equals" => {
1550 if let Some(serde_json::Value::String(s)) = &assertion.value {
1551 let escaped = escape_elixir(s);
1552 let _ = writeln!(out, " assert {expr} == \"{escaped}\"");
1553 } else if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1554 let _ = writeln!(out, " assert {expr} == {n}");
1555 }
1556 }
1557 "not_empty" => {
1558 let _ = writeln!(out, " assert {expr} != []");
1559 }
1560 "is_empty" => {
1561 let _ = writeln!(out, " assert {expr} == []");
1562 }
1563 "is_true" => {
1564 let _ = writeln!(out, " assert {expr}");
1565 }
1566 "is_false" => {
1567 let _ = writeln!(out, " refute {expr}");
1568 }
1569 "greater_than" => {
1570 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1571 let _ = writeln!(out, " assert {expr} > {n}");
1572 }
1573 }
1574 "greater_than_or_equal" => {
1575 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1576 let _ = writeln!(out, " assert {expr} >= {n}");
1577 }
1578 }
1579 "contains" => {
1580 if let Some(serde_json::Value::String(s)) = &assertion.value {
1581 let escaped = escape_elixir(s);
1582 let _ = writeln!(out, " assert String.contains?({expr}, \"{escaped}\")");
1583 }
1584 }
1585 _ => {
1586 let _ = writeln!(
1587 out,
1588 " # streaming field '{f}': assertion type '{}' not rendered",
1589 assertion.assertion_type
1590 );
1591 }
1592 }
1593 }
1594 return;
1595 }
1596 }
1597 }
1598
1599 if !result_is_simple {
1604 if let Some(f) = &assertion.field {
1605 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1606 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1607 return;
1608 }
1609 }
1610 }
1611
1612 let field_expr = if result_is_simple {
1616 result_var.to_string()
1617 } else {
1618 match &assertion.field {
1619 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1620 _ => result_var.to_string(),
1621 }
1622 };
1623
1624 let is_numeric = is_numeric_expr(&field_expr);
1627 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1634 let resolved = field_resolver.resolve(f);
1635 fields_enum.contains(f)
1636 || fields_enum.contains(resolved)
1637 || per_call_enum_fields.contains_key(f)
1638 || per_call_enum_fields.contains_key(resolved)
1639 });
1640 let coerced_field_expr = if field_is_enum {
1641 format!("to_string({field_expr})")
1642 } else {
1643 field_expr.clone()
1644 };
1645 let trimmed_field_expr = if is_numeric {
1646 field_expr.clone()
1647 } else {
1648 format!("String.trim({coerced_field_expr})")
1649 };
1650
1651 let field_is_array = assertion
1654 .field
1655 .as_deref()
1656 .filter(|f| !f.is_empty())
1657 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1658
1659 match assertion.assertion_type.as_str() {
1660 "equals" => {
1661 if let Some(expected) = &assertion.value {
1662 let elixir_val = json_to_elixir(expected);
1663 let is_string_expected = expected.is_string();
1665 if is_string_expected && !is_numeric {
1666 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1667 } else if field_is_enum {
1668 let _ = writeln!(out, " assert {coerced_field_expr} == {elixir_val}");
1669 } else {
1670 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1671 }
1672 }
1673 }
1674 "contains" => {
1675 if let Some(expected) = &assertion.value {
1676 let elixir_val = json_to_elixir(expected);
1677 if field_is_array && expected.is_string() {
1678 let _ = writeln!(
1680 out,
1681 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1682 );
1683 } else {
1684 let _ = writeln!(
1686 out,
1687 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1688 );
1689 }
1690 }
1691 }
1692 "contains_all" => {
1693 if let Some(values) = &assertion.values {
1694 for val in values {
1695 let elixir_val = json_to_elixir(val);
1696 if field_is_array && val.is_string() {
1697 let _ = writeln!(
1698 out,
1699 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1700 );
1701 } else {
1702 let _ = writeln!(
1703 out,
1704 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1705 );
1706 }
1707 }
1708 }
1709 }
1710 "not_contains" => {
1711 if let Some(expected) = &assertion.value {
1712 let elixir_val = json_to_elixir(expected);
1713 if field_is_array && expected.is_string() {
1714 let _ = writeln!(
1715 out,
1716 " refute Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1717 );
1718 } else {
1719 let _ = writeln!(
1720 out,
1721 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1722 );
1723 }
1724 }
1725 }
1726 "not_empty" => {
1727 let _ = writeln!(out, " assert {field_expr} != \"\"");
1728 }
1729 "is_empty" => {
1730 if is_numeric {
1731 let _ = writeln!(out, " assert {field_expr} == 0");
1733 } else {
1734 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1736 }
1737 }
1738 "contains_any" => {
1739 if let Some(values) = &assertion.values {
1740 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1741 let list_str = items.join(", ");
1742 let _ = writeln!(
1743 out,
1744 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1745 );
1746 }
1747 }
1748 "greater_than" => {
1749 if let Some(val) = &assertion.value {
1750 let elixir_val = json_to_elixir(val);
1751 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
1752 }
1753 }
1754 "less_than" => {
1755 if let Some(val) = &assertion.value {
1756 let elixir_val = json_to_elixir(val);
1757 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
1758 }
1759 }
1760 "greater_than_or_equal" => {
1761 if let Some(val) = &assertion.value {
1762 let elixir_val = json_to_elixir(val);
1763 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
1764 }
1765 }
1766 "less_than_or_equal" => {
1767 if let Some(val) = &assertion.value {
1768 let elixir_val = json_to_elixir(val);
1769 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
1770 }
1771 }
1772 "starts_with" => {
1773 if let Some(expected) = &assertion.value {
1774 let elixir_val = json_to_elixir(expected);
1775 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
1776 }
1777 }
1778 "ends_with" => {
1779 if let Some(expected) = &assertion.value {
1780 let elixir_val = json_to_elixir(expected);
1781 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
1782 }
1783 }
1784 "min_length" => {
1785 if let Some(val) = &assertion.value {
1786 if let Some(n) = val.as_u64() {
1787 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1788 }
1789 }
1790 }
1791 "max_length" => {
1792 if let Some(val) = &assertion.value {
1793 if let Some(n) = val.as_u64() {
1794 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
1795 }
1796 }
1797 }
1798 "count_min" => {
1799 if let Some(val) = &assertion.value {
1800 if let Some(n) = val.as_u64() {
1801 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
1802 }
1803 }
1804 }
1805 "count_equals" => {
1806 if let Some(val) = &assertion.value {
1807 if let Some(n) = val.as_u64() {
1808 let _ = writeln!(out, " assert length({field_expr}) == {n}");
1809 }
1810 }
1811 }
1812 "is_true" => {
1813 let _ = writeln!(out, " assert {field_expr} == true");
1814 }
1815 "is_false" => {
1816 let _ = writeln!(out, " assert {field_expr} == false");
1817 }
1818 "method_result" => {
1819 if let Some(method_name) = &assertion.method {
1820 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1821 let check = assertion.check.as_deref().unwrap_or("is_true");
1822 match check {
1823 "equals" => {
1824 if let Some(val) = &assertion.value {
1825 let elixir_val = json_to_elixir(val);
1826 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
1827 }
1828 }
1829 "is_true" => {
1830 let _ = writeln!(out, " assert {call_expr} == true");
1831 }
1832 "is_false" => {
1833 let _ = writeln!(out, " assert {call_expr} == false");
1834 }
1835 "greater_than_or_equal" => {
1836 if let Some(val) = &assertion.value {
1837 let n = val.as_u64().unwrap_or(0);
1838 let _ = writeln!(out, " assert {call_expr} >= {n}");
1839 }
1840 }
1841 "count_min" => {
1842 if let Some(val) = &assertion.value {
1843 let n = val.as_u64().unwrap_or(0);
1844 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
1845 }
1846 }
1847 "contains" => {
1848 if let Some(val) = &assertion.value {
1849 let elixir_val = json_to_elixir(val);
1850 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
1851 }
1852 }
1853 "is_error" => {
1854 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
1855 }
1856 other_check => {
1857 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1858 }
1859 }
1860 } else {
1861 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1862 }
1863 }
1864 "matches_regex" => {
1865 if let Some(expected) = &assertion.value {
1866 let elixir_val = json_to_elixir(expected);
1867 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1868 }
1869 }
1870 "not_error" => {
1871 }
1873 "error" => {
1874 }
1876 other => {
1877 panic!("Elixir e2e generator: unsupported assertion type: {other}");
1878 }
1879 }
1880}
1881
1882fn build_elixir_method_call(
1885 result_var: &str,
1886 method_name: &str,
1887 args: Option<&serde_json::Value>,
1888 module_path: &str,
1889) -> String {
1890 match method_name {
1891 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1892 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1893 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1894 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1895 "contains_node_type" => {
1896 let node_type = args
1897 .and_then(|a| a.get("node_type"))
1898 .and_then(|v| v.as_str())
1899 .unwrap_or("");
1900 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1901 }
1902 "find_nodes_by_type" => {
1903 let node_type = args
1904 .and_then(|a| a.get("node_type"))
1905 .and_then(|v| v.as_str())
1906 .unwrap_or("");
1907 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1908 }
1909 "run_query" => {
1910 let query_source = args
1911 .and_then(|a| a.get("query_source"))
1912 .and_then(|v| v.as_str())
1913 .unwrap_or("");
1914 let language = args
1915 .and_then(|a| a.get("language"))
1916 .and_then(|v| v.as_str())
1917 .unwrap_or("");
1918 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1919 }
1920 _ => format!("{module_path}.{method_name}({result_var})"),
1921 }
1922}
1923
1924fn elixir_module_name(category: &str) -> String {
1926 use heck::ToUpperCamelCase;
1927 category.to_upper_camel_case()
1928}
1929
1930fn json_to_elixir(value: &serde_json::Value) -> String {
1932 match value {
1933 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1934 serde_json::Value::Bool(true) => "true".to_string(),
1935 serde_json::Value::Bool(false) => "false".to_string(),
1936 serde_json::Value::Number(n) => {
1937 let s = n.to_string().replace("e+", "e");
1941 if s.contains('e') && !s.contains('.') {
1942 s.replacen('e', ".0e", 1)
1944 } else {
1945 s
1946 }
1947 }
1948 serde_json::Value::Null => "nil".to_string(),
1949 serde_json::Value::Array(arr) => {
1950 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1951 format!("[{}]", items.join(", "))
1952 }
1953 serde_json::Value::Object(map) => {
1954 let entries: Vec<String> = map
1955 .iter()
1956 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1957 .collect();
1958 format!("%{{{}}}", entries.join(", "))
1959 }
1960 }
1961}
1962
1963fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1965 use std::fmt::Write as FmtWrite;
1966 let mut visitor_obj = String::new();
1967 let _ = writeln!(visitor_obj, "%{{");
1968 for (method_name, action) in &visitor_spec.callbacks {
1969 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1970 }
1971 let _ = writeln!(visitor_obj, " }}");
1972
1973 setup_lines.push(format!("visitor = {visitor_obj}"));
1974 "visitor".to_string()
1975}
1976
1977fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1979 use std::fmt::Write as FmtWrite;
1980
1981 let handle_method = format!("handle_{}", &method_name[6..]); let arg_binding = match action {
1991 CallbackAction::CustomTemplate { .. } => "args",
1992 _ => "_args",
1993 };
1994 let _ = writeln!(out, " :{handle_method} => fn({arg_binding}) ->");
1995 match action {
1996 CallbackAction::Skip => {
1997 let _ = writeln!(out, " :skip");
1998 }
1999 CallbackAction::Continue => {
2000 let _ = writeln!(out, " :continue");
2001 }
2002 CallbackAction::PreserveHtml => {
2003 let _ = writeln!(out, " :preserve_html");
2004 }
2005 CallbackAction::Custom { output } => {
2006 let escaped = escape_elixir(output);
2007 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
2008 }
2009 CallbackAction::CustomTemplate { template, .. } => {
2010 let expr = template_to_elixir_concat(template);
2014 let _ = writeln!(out, " {{:custom, {expr}}}");
2015 }
2016 }
2017 let _ = writeln!(out, " end,");
2018}
2019
2020fn template_to_elixir_concat(template: &str) -> String {
2025 let mut parts: Vec<String> = Vec::new();
2026 let mut static_buf = String::new();
2027 let mut chars = template.chars().peekable();
2028
2029 while let Some(ch) = chars.next() {
2030 if ch == '{' {
2031 let mut key = String::new();
2032 let mut closed = false;
2033 for kc in chars.by_ref() {
2034 if kc == '}' {
2035 closed = true;
2036 break;
2037 }
2038 key.push(kc);
2039 }
2040 if closed && !key.is_empty() {
2041 if !static_buf.is_empty() {
2042 let escaped = escape_elixir(&static_buf);
2043 parts.push(format!("\"{escaped}\""));
2044 static_buf.clear();
2045 }
2046 let escaped_key = escape_elixir(&key);
2047 parts.push(format!("Map.get(args, \"{escaped_key}\", \"\")"));
2048 } else {
2049 static_buf.push('{');
2050 static_buf.push_str(&key);
2051 if !closed {
2052 }
2054 }
2055 } else {
2056 static_buf.push(ch);
2057 }
2058 }
2059
2060 if !static_buf.is_empty() {
2061 let escaped = escape_elixir(&static_buf);
2062 parts.push(format!("\"{escaped}\""));
2063 }
2064
2065 if parts.is_empty() {
2066 return "\"\"".to_string();
2067 }
2068 parts.join(" <> ")
2069}
2070
2071fn fixture_has_elixir_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
2072 if fixture.is_http_test() {
2074 return false;
2075 }
2076 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
2077 let elixir_override = call_config
2078 .overrides
2079 .get("elixir")
2080 .or_else(|| e2e_config.call.overrides.get("elixir"));
2081 if elixir_override.and_then(|o| o.client_factory.as_deref()).is_some() {
2083 return true;
2084 }
2085 let function_from_override = elixir_override.and_then(|o| o.function.as_deref());
2090
2091 function_from_override.is_some() || !call_config.function.is_empty()
2093}