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, " {{\"test-key\", {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 = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
1044 let chunks_var = "chunks";
1046
1047 if returns_result {
1048 let _ = writeln!(
1049 out,
1050 " {{:ok, {result_var}}} = {module_path}.{function_name}({effective_args})"
1051 );
1052 } else {
1053 let _ = writeln!(
1055 out,
1056 " {result_var} = {module_path}.{function_name}({effective_args})"
1057 );
1058 }
1059
1060 if is_streaming {
1062 if let Some(collect) = crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(
1063 "elixir",
1064 &result_var,
1065 chunks_var,
1066 ) {
1067 let _ = writeln!(out, " {collect}");
1068 }
1069 }
1070
1071 for assertion in &fixture.assertions {
1072 render_assertion(
1073 out,
1074 assertion,
1075 if is_streaming { chunks_var } else { &result_var },
1076 field_resolver,
1077 &module_path,
1078 &e2e_config.fields_enum,
1079 resolved_enum_fields_ref,
1080 result_is_simple,
1081 is_streaming,
1082 );
1083 }
1084
1085 if needs_api_key_skip {
1086 let _ = writeln!(out, " end");
1087 }
1088 let _ = writeln!(out, " end");
1089 let _ = writeln!(out, " end");
1090}
1091
1092#[allow(clippy::too_many_arguments)]
1096fn emit_elixir_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1098 if let Some(items) = arr.as_array() {
1099 let item_strs: Vec<String> = items
1100 .iter()
1101 .filter_map(|item| {
1102 if let Some(obj) = item.as_object() {
1103 match elem_type {
1104 "BatchBytesItem" => {
1105 let content = obj.get("content").and_then(|v| v.as_array());
1106 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1107 let content_code = if let Some(arr) = content {
1108 let bytes: Vec<String> =
1109 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1110 format!("<<{}>>", bytes.join(", "))
1111 } else {
1112 "<<>>".to_string()
1113 };
1114 Some(format!(
1115 "%BatchBytesItem{{content: {}, mime_type: \"{}\"}}",
1116 content_code, mime_type
1117 ))
1118 }
1119 "BatchFileItem" => {
1120 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1121 Some(format!("%BatchFileItem{{path: \"{}\"}}", path))
1122 }
1123 _ => None,
1124 }
1125 } else {
1126 None
1127 }
1128 })
1129 .collect();
1130 format!("[{}]", item_strs.join(", "))
1131 } else {
1132 "[]".to_string()
1133 }
1134}
1135
1136#[allow(clippy::too_many_arguments)]
1137fn build_args_and_setup(
1138 input: &serde_json::Value,
1139 args: &[crate::config::ArgMapping],
1140 module_path: &str,
1141 options_type: Option<&str>,
1142 options_default_fn: Option<&str>,
1143 enum_fields: &HashMap<String, String>,
1144 fixture: &crate::fixture::Fixture,
1145 _handle_struct_type: Option<&str>,
1146 _handle_atom_list_fields: &std::collections::HashSet<String>,
1147 test_documents_path: &str,
1148) -> (Vec<String>, String) {
1149 let fixture_id = &fixture.id;
1150 if args.is_empty() {
1151 let is_empty_input = match input {
1155 serde_json::Value::Null => true,
1156 serde_json::Value::Object(m) => m.is_empty(),
1157 _ => false,
1158 };
1159 if is_empty_input {
1160 return (Vec::new(), String::new());
1161 }
1162 return (Vec::new(), json_to_elixir(input));
1163 }
1164
1165 let mut setup_lines: Vec<String> = Vec::new();
1166 let mut parts: Vec<String> = Vec::new();
1167
1168 for arg in args {
1169 if arg.arg_type == "mock_url" {
1170 if fixture.has_host_root_route() {
1171 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1172 setup_lines.push(format!(
1173 "{} = System.get_env(\"{env_key}\") || (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1174 arg.name,
1175 ));
1176 } else {
1177 setup_lines.push(format!(
1178 "{} = (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1179 arg.name,
1180 ));
1181 }
1182 parts.push(arg.name.clone());
1183 continue;
1184 }
1185
1186 if arg.arg_type == "handle" {
1187 let constructor_name = format!("create_{}", arg.name.to_snake_case());
1191 let config_value = if arg.field == "input" {
1192 input
1193 } else {
1194 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1195 input.get(field).unwrap_or(&serde_json::Value::Null)
1196 };
1197 let name = &arg.name;
1198 if config_value.is_null()
1199 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1200 {
1201 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
1202 } else {
1203 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
1206 let escaped = escape_elixir(&json_str);
1207 setup_lines.push(format!("{name}_config = \"{escaped}\""));
1208 setup_lines.push(format!(
1209 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
1210 ));
1211 }
1212 parts.push(arg.name.clone());
1213 continue;
1214 }
1215
1216 let val = if arg.field == "input" {
1217 Some(input)
1218 } else {
1219 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1220 input.get(field)
1221 };
1222 match val {
1223 None | Some(serde_json::Value::Null) if arg.optional => {
1224 parts.push("nil".to_string());
1227 continue;
1228 }
1229 None | Some(serde_json::Value::Null) => {
1230 let default_val = match arg.arg_type.as_str() {
1232 "string" => "\"\"".to_string(),
1233 "int" | "integer" => "0".to_string(),
1234 "float" | "number" => "0.0".to_string(),
1235 "bool" | "boolean" => "false".to_string(),
1236 _ => "nil".to_string(),
1237 };
1238 parts.push(default_val);
1239 }
1240 Some(v) => {
1241 if arg.arg_type == "file_path" {
1244 if let Some(path_str) = v.as_str() {
1245 let full_path = format!("{test_documents_path}/{path_str}");
1246 parts.push(format!("\"{}\"", escape_elixir(&full_path)));
1247 continue;
1248 }
1249 }
1250 if arg.arg_type == "bytes" {
1253 if let Some(raw) = v.as_str() {
1254 let var_name = &arg.name;
1255 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
1256 parts.push(format!("\"{}\"", escape_elixir(raw)));
1258 } else {
1259 let first = raw.chars().next().unwrap_or('\0');
1260 let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
1261 && raw
1262 .find('/')
1263 .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
1264 if is_file_path {
1265 let full_path = format!("{test_documents_path}/{raw}");
1268 let escaped = escape_elixir(&full_path);
1269 setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
1270 parts.push(var_name.to_string());
1271 } else {
1272 setup_lines.push(format!(
1274 "{var_name} = Base.decode64!(\"{}\", padding: false)",
1275 escape_elixir(raw)
1276 ));
1277 parts.push(var_name.to_string());
1278 }
1279 }
1280 continue;
1281 }
1282 }
1283 if arg.arg_type == "json_object" && !v.is_null() {
1285 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
1286 (options_type, options_default_fn, v.as_object())
1287 {
1288 let options_var = "options";
1290 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
1291
1292 for (k, vv) in obj.iter() {
1294 let snake_key = k.to_snake_case();
1295 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1296 if let Some(s) = vv.as_str() {
1297 let snake_val = s.to_snake_case();
1298 format!(":{snake_val}")
1300 } else {
1301 json_to_elixir(vv)
1302 }
1303 } else {
1304 json_to_elixir(vv)
1305 };
1306 setup_lines.push(format!(
1307 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
1308 ));
1309 }
1310
1311 parts.push(options_var.to_string());
1313 continue;
1314 }
1315 if let Some(elem_type) = &arg.element_type {
1317 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1318 parts.push(emit_elixir_batch_item_array(v, elem_type));
1319 continue;
1320 }
1321 if v.is_array() {
1324 parts.push(json_to_elixir(v));
1325 continue;
1326 }
1327 }
1328 if !v.is_null() {
1332 let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
1333 let escaped = escape_elixir(&json_str);
1334 parts.push(format!("\"{escaped}\""));
1335 continue;
1336 }
1337 }
1338 parts.push(json_to_elixir(v));
1339 }
1340 }
1341 }
1342
1343 (setup_lines, parts.join(", "))
1344}
1345
1346fn is_numeric_expr(field_expr: &str) -> bool {
1349 field_expr.starts_with("length(")
1350}
1351
1352#[allow(clippy::too_many_arguments)]
1353fn render_assertion(
1354 out: &mut String,
1355 assertion: &Assertion,
1356 result_var: &str,
1357 field_resolver: &FieldResolver,
1358 module_path: &str,
1359 fields_enum: &std::collections::HashSet<String>,
1360 per_call_enum_fields: &HashMap<String, String>,
1361 result_is_simple: bool,
1362 is_streaming: bool,
1363) {
1364 if let Some(f) = &assertion.field {
1367 match f.as_str() {
1368 "chunks_have_content" => {
1369 let pred =
1370 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
1371 match assertion.assertion_type.as_str() {
1372 "is_true" => {
1373 let _ = writeln!(out, " assert {pred}");
1374 }
1375 "is_false" => {
1376 let _ = writeln!(out, " refute {pred}");
1377 }
1378 _ => {
1379 let _ = writeln!(
1380 out,
1381 " # skipped: unsupported assertion type on synthetic field '{f}'"
1382 );
1383 }
1384 }
1385 return;
1386 }
1387 "chunks_have_embeddings" => {
1388 let pred = format!(
1389 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
1390 );
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 "embeddings" => {
1411 match assertion.assertion_type.as_str() {
1412 "count_equals" => {
1413 if let Some(val) = &assertion.value {
1414 let ex_val = json_to_elixir(val);
1415 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
1416 }
1417 }
1418 "count_min" => {
1419 if let Some(val) = &assertion.value {
1420 let ex_val = json_to_elixir(val);
1421 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
1422 }
1423 }
1424 "not_empty" => {
1425 let _ = writeln!(out, " assert {result_var} != []");
1426 }
1427 "is_empty" => {
1428 let _ = writeln!(out, " assert {result_var} == []");
1429 }
1430 _ => {
1431 let _ = writeln!(
1432 out,
1433 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1434 );
1435 }
1436 }
1437 return;
1438 }
1439 "embedding_dimensions" => {
1440 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1441 match assertion.assertion_type.as_str() {
1442 "equals" => {
1443 if let Some(val) = &assertion.value {
1444 let ex_val = json_to_elixir(val);
1445 let _ = writeln!(out, " assert {expr} == {ex_val}");
1446 }
1447 }
1448 "greater_than" => {
1449 if let Some(val) = &assertion.value {
1450 let ex_val = json_to_elixir(val);
1451 let _ = writeln!(out, " assert {expr} > {ex_val}");
1452 }
1453 }
1454 _ => {
1455 let _ = writeln!(
1456 out,
1457 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1458 );
1459 }
1460 }
1461 return;
1462 }
1463 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1464 let pred = match f.as_str() {
1465 "embeddings_valid" => {
1466 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1467 }
1468 "embeddings_finite" => {
1469 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1470 }
1471 "embeddings_non_zero" => {
1472 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1473 }
1474 "embeddings_normalized" => {
1475 format!(
1476 "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)"
1477 )
1478 }
1479 _ => unreachable!(),
1480 };
1481 match assertion.assertion_type.as_str() {
1482 "is_true" => {
1483 let _ = writeln!(out, " assert {pred}");
1484 }
1485 "is_false" => {
1486 let _ = writeln!(out, " refute {pred}");
1487 }
1488 _ => {
1489 let _ = writeln!(
1490 out,
1491 " # skipped: unsupported assertion type on synthetic field '{f}'"
1492 );
1493 }
1494 }
1495 return;
1496 }
1497 "keywords" | "keywords_count" => {
1500 let _ = writeln!(
1501 out,
1502 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1503 );
1504 return;
1505 }
1506 _ => {}
1507 }
1508 }
1509
1510 if is_streaming {
1513 if let Some(f) = &assertion.field {
1514 if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1515 if let Some(expr) =
1516 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "elixir", result_var)
1517 {
1518 match assertion.assertion_type.as_str() {
1519 "count_min" => {
1520 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1521 let _ = writeln!(out, " assert length({expr}) >= {n}");
1522 }
1523 }
1524 "count_equals" => {
1525 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1526 let _ = writeln!(out, " assert length({expr}) == {n}");
1527 }
1528 }
1529 "equals" => {
1530 if let Some(serde_json::Value::String(s)) = &assertion.value {
1531 let escaped = escape_elixir(s);
1532 let _ = writeln!(out, " assert {expr} == \"{escaped}\"");
1533 } else if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1534 let _ = writeln!(out, " assert {expr} == {n}");
1535 }
1536 }
1537 "not_empty" => {
1538 let _ = writeln!(out, " assert {expr} != []");
1539 }
1540 "is_empty" => {
1541 let _ = writeln!(out, " assert {expr} == []");
1542 }
1543 "is_true" => {
1544 let _ = writeln!(out, " assert {expr}");
1545 }
1546 "is_false" => {
1547 let _ = writeln!(out, " refute {expr}");
1548 }
1549 "greater_than" => {
1550 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1551 let _ = writeln!(out, " assert {expr} > {n}");
1552 }
1553 }
1554 "greater_than_or_equal" => {
1555 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1556 let _ = writeln!(out, " assert {expr} >= {n}");
1557 }
1558 }
1559 "contains" => {
1560 if let Some(serde_json::Value::String(s)) = &assertion.value {
1561 let escaped = escape_elixir(s);
1562 let _ = writeln!(out, " assert String.contains?({expr}, \"{escaped}\")");
1563 }
1564 }
1565 _ => {
1566 let _ = writeln!(
1567 out,
1568 " # streaming field '{f}': assertion type '{}' not rendered",
1569 assertion.assertion_type
1570 );
1571 }
1572 }
1573 }
1574 return;
1575 }
1576 }
1577 }
1578
1579 if !result_is_simple {
1584 if let Some(f) = &assertion.field {
1585 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1586 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1587 return;
1588 }
1589 }
1590 }
1591
1592 let field_expr = if result_is_simple {
1596 result_var.to_string()
1597 } else {
1598 match &assertion.field {
1599 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1600 _ => result_var.to_string(),
1601 }
1602 };
1603
1604 let is_numeric = is_numeric_expr(&field_expr);
1607 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1614 let resolved = field_resolver.resolve(f);
1615 fields_enum.contains(f)
1616 || fields_enum.contains(resolved)
1617 || per_call_enum_fields.contains_key(f)
1618 || per_call_enum_fields.contains_key(resolved)
1619 });
1620 let coerced_field_expr = if field_is_enum {
1621 format!("to_string({field_expr})")
1622 } else {
1623 field_expr.clone()
1624 };
1625 let trimmed_field_expr = if is_numeric {
1626 field_expr.clone()
1627 } else {
1628 format!("String.trim({coerced_field_expr})")
1629 };
1630
1631 let field_is_array = assertion
1634 .field
1635 .as_deref()
1636 .filter(|f| !f.is_empty())
1637 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1638
1639 match assertion.assertion_type.as_str() {
1640 "equals" => {
1641 if let Some(expected) = &assertion.value {
1642 let elixir_val = json_to_elixir(expected);
1643 let is_string_expected = expected.is_string();
1645 if is_string_expected && !is_numeric {
1646 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1647 } else if field_is_enum {
1648 let _ = writeln!(out, " assert {coerced_field_expr} == {elixir_val}");
1649 } else {
1650 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1651 }
1652 }
1653 }
1654 "contains" => {
1655 if let Some(expected) = &assertion.value {
1656 let elixir_val = json_to_elixir(expected);
1657 if field_is_array && expected.is_string() {
1658 let _ = writeln!(
1660 out,
1661 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1662 );
1663 } else {
1664 let _ = writeln!(
1666 out,
1667 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1668 );
1669 }
1670 }
1671 }
1672 "contains_all" => {
1673 if let Some(values) = &assertion.values {
1674 for val in values {
1675 let elixir_val = json_to_elixir(val);
1676 if field_is_array && val.is_string() {
1677 let _ = writeln!(
1678 out,
1679 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1680 );
1681 } else {
1682 let _ = writeln!(
1683 out,
1684 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1685 );
1686 }
1687 }
1688 }
1689 }
1690 "not_contains" => {
1691 if let Some(expected) = &assertion.value {
1692 let elixir_val = json_to_elixir(expected);
1693 if field_is_array && expected.is_string() {
1694 let _ = writeln!(
1695 out,
1696 " refute Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1697 );
1698 } else {
1699 let _ = writeln!(
1700 out,
1701 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1702 );
1703 }
1704 }
1705 }
1706 "not_empty" => {
1707 let _ = writeln!(out, " assert {field_expr} != \"\"");
1708 }
1709 "is_empty" => {
1710 if is_numeric {
1711 let _ = writeln!(out, " assert {field_expr} == 0");
1713 } else {
1714 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1716 }
1717 }
1718 "contains_any" => {
1719 if let Some(values) = &assertion.values {
1720 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1721 let list_str = items.join(", ");
1722 let _ = writeln!(
1723 out,
1724 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1725 );
1726 }
1727 }
1728 "greater_than" => {
1729 if let Some(val) = &assertion.value {
1730 let elixir_val = json_to_elixir(val);
1731 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
1732 }
1733 }
1734 "less_than" => {
1735 if let Some(val) = &assertion.value {
1736 let elixir_val = json_to_elixir(val);
1737 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
1738 }
1739 }
1740 "greater_than_or_equal" => {
1741 if let Some(val) = &assertion.value {
1742 let elixir_val = json_to_elixir(val);
1743 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
1744 }
1745 }
1746 "less_than_or_equal" => {
1747 if let Some(val) = &assertion.value {
1748 let elixir_val = json_to_elixir(val);
1749 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
1750 }
1751 }
1752 "starts_with" => {
1753 if let Some(expected) = &assertion.value {
1754 let elixir_val = json_to_elixir(expected);
1755 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
1756 }
1757 }
1758 "ends_with" => {
1759 if let Some(expected) = &assertion.value {
1760 let elixir_val = json_to_elixir(expected);
1761 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
1762 }
1763 }
1764 "min_length" => {
1765 if let Some(val) = &assertion.value {
1766 if let Some(n) = val.as_u64() {
1767 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1768 }
1769 }
1770 }
1771 "max_length" => {
1772 if let Some(val) = &assertion.value {
1773 if let Some(n) = val.as_u64() {
1774 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
1775 }
1776 }
1777 }
1778 "count_min" => {
1779 if let Some(val) = &assertion.value {
1780 if let Some(n) = val.as_u64() {
1781 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
1782 }
1783 }
1784 }
1785 "count_equals" => {
1786 if let Some(val) = &assertion.value {
1787 if let Some(n) = val.as_u64() {
1788 let _ = writeln!(out, " assert length({field_expr}) == {n}");
1789 }
1790 }
1791 }
1792 "is_true" => {
1793 let _ = writeln!(out, " assert {field_expr} == true");
1794 }
1795 "is_false" => {
1796 let _ = writeln!(out, " assert {field_expr} == false");
1797 }
1798 "method_result" => {
1799 if let Some(method_name) = &assertion.method {
1800 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1801 let check = assertion.check.as_deref().unwrap_or("is_true");
1802 match check {
1803 "equals" => {
1804 if let Some(val) = &assertion.value {
1805 let elixir_val = json_to_elixir(val);
1806 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
1807 }
1808 }
1809 "is_true" => {
1810 let _ = writeln!(out, " assert {call_expr} == true");
1811 }
1812 "is_false" => {
1813 let _ = writeln!(out, " assert {call_expr} == false");
1814 }
1815 "greater_than_or_equal" => {
1816 if let Some(val) = &assertion.value {
1817 let n = val.as_u64().unwrap_or(0);
1818 let _ = writeln!(out, " assert {call_expr} >= {n}");
1819 }
1820 }
1821 "count_min" => {
1822 if let Some(val) = &assertion.value {
1823 let n = val.as_u64().unwrap_or(0);
1824 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
1825 }
1826 }
1827 "contains" => {
1828 if let Some(val) = &assertion.value {
1829 let elixir_val = json_to_elixir(val);
1830 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
1831 }
1832 }
1833 "is_error" => {
1834 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
1835 }
1836 other_check => {
1837 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1838 }
1839 }
1840 } else {
1841 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1842 }
1843 }
1844 "matches_regex" => {
1845 if let Some(expected) = &assertion.value {
1846 let elixir_val = json_to_elixir(expected);
1847 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1848 }
1849 }
1850 "not_error" => {
1851 }
1853 "error" => {
1854 }
1856 other => {
1857 panic!("Elixir e2e generator: unsupported assertion type: {other}");
1858 }
1859 }
1860}
1861
1862fn build_elixir_method_call(
1865 result_var: &str,
1866 method_name: &str,
1867 args: Option<&serde_json::Value>,
1868 module_path: &str,
1869) -> String {
1870 match method_name {
1871 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1872 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1873 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1874 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1875 "contains_node_type" => {
1876 let node_type = args
1877 .and_then(|a| a.get("node_type"))
1878 .and_then(|v| v.as_str())
1879 .unwrap_or("");
1880 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1881 }
1882 "find_nodes_by_type" => {
1883 let node_type = args
1884 .and_then(|a| a.get("node_type"))
1885 .and_then(|v| v.as_str())
1886 .unwrap_or("");
1887 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1888 }
1889 "run_query" => {
1890 let query_source = args
1891 .and_then(|a| a.get("query_source"))
1892 .and_then(|v| v.as_str())
1893 .unwrap_or("");
1894 let language = args
1895 .and_then(|a| a.get("language"))
1896 .and_then(|v| v.as_str())
1897 .unwrap_or("");
1898 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1899 }
1900 _ => format!("{module_path}.{method_name}({result_var})"),
1901 }
1902}
1903
1904fn elixir_module_name(category: &str) -> String {
1906 use heck::ToUpperCamelCase;
1907 category.to_upper_camel_case()
1908}
1909
1910fn json_to_elixir(value: &serde_json::Value) -> String {
1912 match value {
1913 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1914 serde_json::Value::Bool(true) => "true".to_string(),
1915 serde_json::Value::Bool(false) => "false".to_string(),
1916 serde_json::Value::Number(n) => {
1917 let s = n.to_string().replace("e+", "e");
1921 if s.contains('e') && !s.contains('.') {
1922 s.replacen('e', ".0e", 1)
1924 } else {
1925 s
1926 }
1927 }
1928 serde_json::Value::Null => "nil".to_string(),
1929 serde_json::Value::Array(arr) => {
1930 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1931 format!("[{}]", items.join(", "))
1932 }
1933 serde_json::Value::Object(map) => {
1934 let entries: Vec<String> = map
1935 .iter()
1936 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1937 .collect();
1938 format!("%{{{}}}", entries.join(", "))
1939 }
1940 }
1941}
1942
1943fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1945 use std::fmt::Write as FmtWrite;
1946 let mut visitor_obj = String::new();
1947 let _ = writeln!(visitor_obj, "%{{");
1948 for (method_name, action) in &visitor_spec.callbacks {
1949 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1950 }
1951 let _ = writeln!(visitor_obj, " }}");
1952
1953 setup_lines.push(format!("visitor = {visitor_obj}"));
1954 "visitor".to_string()
1955}
1956
1957fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1959 use std::fmt::Write as FmtWrite;
1960
1961 let handle_method = format!("handle_{}", &method_name[6..]); let arg_binding = match action {
1971 CallbackAction::CustomTemplate { .. } => "args",
1972 _ => "_args",
1973 };
1974 let _ = writeln!(out, " :{handle_method} => fn({arg_binding}) ->");
1975 match action {
1976 CallbackAction::Skip => {
1977 let _ = writeln!(out, " :skip");
1978 }
1979 CallbackAction::Continue => {
1980 let _ = writeln!(out, " :continue");
1981 }
1982 CallbackAction::PreserveHtml => {
1983 let _ = writeln!(out, " :preserve_html");
1984 }
1985 CallbackAction::Custom { output } => {
1986 let escaped = escape_elixir(output);
1987 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1988 }
1989 CallbackAction::CustomTemplate { template, .. } => {
1990 let expr = template_to_elixir_concat(template);
1994 let _ = writeln!(out, " {{:custom, {expr}}}");
1995 }
1996 }
1997 let _ = writeln!(out, " end,");
1998}
1999
2000fn template_to_elixir_concat(template: &str) -> String {
2005 let mut parts: Vec<String> = Vec::new();
2006 let mut static_buf = String::new();
2007 let mut chars = template.chars().peekable();
2008
2009 while let Some(ch) = chars.next() {
2010 if ch == '{' {
2011 let mut key = String::new();
2012 let mut closed = false;
2013 for kc in chars.by_ref() {
2014 if kc == '}' {
2015 closed = true;
2016 break;
2017 }
2018 key.push(kc);
2019 }
2020 if closed && !key.is_empty() {
2021 if !static_buf.is_empty() {
2022 let escaped = escape_elixir(&static_buf);
2023 parts.push(format!("\"{escaped}\""));
2024 static_buf.clear();
2025 }
2026 let escaped_key = escape_elixir(&key);
2027 parts.push(format!("Map.get(args, \"{escaped_key}\", \"\")"));
2028 } else {
2029 static_buf.push('{');
2030 static_buf.push_str(&key);
2031 if !closed {
2032 }
2034 }
2035 } else {
2036 static_buf.push(ch);
2037 }
2038 }
2039
2040 if !static_buf.is_empty() {
2041 let escaped = escape_elixir(&static_buf);
2042 parts.push(format!("\"{escaped}\""));
2043 }
2044
2045 if parts.is_empty() {
2046 return "\"\"".to_string();
2047 }
2048 parts.join(" <> ")
2049}
2050
2051fn fixture_has_elixir_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
2052 if fixture.is_http_test() {
2054 return false;
2055 }
2056 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
2057 let elixir_override = call_config
2058 .overrides
2059 .get("elixir")
2060 .or_else(|| e2e_config.call.overrides.get("elixir"));
2061 if elixir_override.and_then(|o| o.client_factory.as_deref()).is_some() {
2063 return true;
2064 }
2065 let function_from_override = elixir_override.and_then(|o| o.function.as_deref());
2070
2071 function_from_override.is_some() || !call_config.function.is_empty()
2073}