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