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 ) -> Result<Vec<GeneratedFile>> {
30 let lang = self.language_name();
31 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
32
33 let mut files = Vec::new();
34
35 let call = &e2e_config.call;
37 let overrides = call.overrides.get(lang);
38 let raw_module = overrides
39 .and_then(|o| o.module.as_ref())
40 .cloned()
41 .unwrap_or_else(|| call.module.clone());
42 let module_path = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase()) {
46 raw_module.clone()
47 } else {
48 elixir_module_name(&raw_module)
49 };
50 let base_function_name = overrides
51 .and_then(|o| o.function.as_ref())
52 .cloned()
53 .unwrap_or_else(|| call.function.clone());
54 let function_name =
59 if call.r#async && !base_function_name.ends_with("_async") && !base_function_name.ends_with("_stream") {
60 format!("{base_function_name}_async")
61 } else {
62 base_function_name
63 };
64 let options_type = overrides.and_then(|o| o.options_type.clone());
65 let options_default_fn = overrides.and_then(|o| o.options_via.clone());
66 let empty_enum_fields = HashMap::new();
67 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
68 let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
69 let empty_atom_fields = std::collections::HashSet::new();
70 let handle_atom_list_fields = overrides
71 .map(|o| &o.handle_atom_list_fields)
72 .unwrap_or(&empty_atom_fields);
73 let result_var = &call.result_var;
74
75 let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
77 let has_nif_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| !f.is_http_test()));
78 let has_mock_server_tests = groups.iter().any(|g| {
80 g.fixtures.iter().any(|f| {
81 if f.needs_mock_server() {
82 return true;
83 }
84 let cc = e2e_config.resolve_call(f.call.as_deref());
85 let elixir_override = cc
86 .overrides
87 .get("elixir")
88 .or_else(|| e2e_config.call.overrides.get("elixir"));
89 elixir_override.and_then(|o| o.client_factory.as_deref()).is_some()
90 })
91 });
92
93 let pkg_ref = e2e_config.resolve_package(lang);
95 let pkg_path = if has_nif_tests {
96 pkg_ref.as_ref().and_then(|p| p.path.as_deref()).unwrap_or("")
97 } else {
98 ""
99 };
100
101 let pkg_atom = config.elixir_app_name();
110 files.push(GeneratedFile {
111 path: output_base.join("mix.exs"),
112 content: render_mix_exs(&pkg_atom, pkg_path, e2e_config.dep_mode, has_http_tests, has_nif_tests),
113 generated_header: false,
114 });
115
116 files.push(GeneratedFile {
118 path: output_base.join("lib").join("e2e_elixir.ex"),
119 content: "defmodule E2eElixir do\n @moduledoc false\nend\n".to_string(),
120 generated_header: false,
121 });
122
123 files.push(GeneratedFile {
125 path: output_base.join("test").join("test_helper.exs"),
126 content: render_test_helper(has_http_tests || has_mock_server_tests),
127 generated_header: false,
128 });
129
130 for group in groups {
132 let active: Vec<&Fixture> = group
133 .fixtures
134 .iter()
135 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
136 .collect();
137
138 if active.is_empty() {
139 continue;
140 }
141
142 let filename = format!("{}_test.exs", sanitize_filename(&group.category));
143 let field_resolver = FieldResolver::new(
144 &e2e_config.fields,
145 &e2e_config.fields_optional,
146 &e2e_config.result_fields,
147 &e2e_config.fields_array,
148 &std::collections::HashSet::new(),
149 );
150 let content = render_test_file(
151 &group.category,
152 &active,
153 e2e_config,
154 &module_path,
155 &function_name,
156 result_var,
157 &e2e_config.call.args,
158 &field_resolver,
159 options_type.as_deref(),
160 options_default_fn.as_deref(),
161 enum_fields,
162 handle_struct_type.as_deref(),
163 handle_atom_list_fields,
164 );
165 files.push(GeneratedFile {
166 path: output_base.join("test").join(filename),
167 content,
168 generated_header: true,
169 });
170 }
171
172 Ok(files)
173 }
174
175 fn language_name(&self) -> &'static str {
176 "elixir"
177 }
178}
179
180fn render_test_helper(has_http_tests: bool) -> String {
181 if has_http_tests {
182 r#"ExUnit.start()
183
184# Spawn mock-server binary and set MOCK_SERVER_URL for all tests.
185mock_server_bin = Path.expand("../../rust/target/release/mock-server", __DIR__)
186fixtures_dir = Path.expand("../../../fixtures", __DIR__)
187
188if File.exists?(mock_server_bin) do
189 port = Port.open({:spawn_executable, mock_server_bin}, [
190 :binary,
191 :line,
192 args: [fixtures_dir]
193 ])
194 receive do
195 {^port, {:data, {:eol, "MOCK_SERVER_URL=" <> url}}} ->
196 System.put_env("MOCK_SERVER_URL", url)
197 after
198 30_000 ->
199 raise "mock-server startup timeout"
200 end
201end
202"#
203 .to_string()
204 } else {
205 "ExUnit.start()\n".to_string()
206 }
207}
208
209fn render_mix_exs(
210 pkg_name: &str,
211 pkg_path: &str,
212 dep_mode: crate::config::DependencyMode,
213 has_http_tests: bool,
214 has_nif_tests: bool,
215) -> String {
216 let mut out = String::new();
217 let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
218 let _ = writeln!(out, " use Mix.Project");
219 let _ = writeln!(out);
220 let _ = writeln!(out, " def project do");
221 let _ = writeln!(out, " [");
222 let _ = writeln!(out, " app: :e2e_elixir,");
223 let _ = writeln!(out, " version: \"0.1.0\",");
224 let _ = writeln!(out, " elixir: \"~> 1.14\",");
225 let _ = writeln!(out, " deps: deps()");
226 let _ = writeln!(out, " ]");
227 let _ = writeln!(out, " end");
228 let _ = writeln!(out);
229 let _ = writeln!(out, " defp deps do");
230 let _ = writeln!(out, " [");
231
232 let mut deps: Vec<String> = Vec::new();
234
235 if has_nif_tests && !pkg_path.is_empty() {
237 let pkg_atom = pkg_name;
238 let nif_dep = match dep_mode {
239 crate::config::DependencyMode::Local => {
240 format!(" {{:{pkg_atom}, path: \"{pkg_path}\"}}")
241 }
242 crate::config::DependencyMode::Registry => {
243 format!(" {{:{pkg_atom}, \"{pkg_path}\"}}")
245 }
246 };
247 deps.push(nif_dep);
248 deps.push(format!(
250 " {{:rustler_precompiled, \"{rp}\"}}",
251 rp = tv::hex::RUSTLER_PRECOMPILED
252 ));
253 deps.push(format!(
258 " {{:rustler, \"{rustler}\", runtime: false}}",
259 rustler = tv::hex::RUSTLER
260 ));
261 }
262
263 if has_http_tests {
265 deps.push(format!(" {{:req, \"{req}\"}}", req = tv::hex::REQ));
266 deps.push(format!(" {{:jason, \"{jason}\"}}", jason = tv::hex::JASON));
267 }
268
269 let _ = writeln!(out, "{}", deps.join(",\n"));
270 let _ = writeln!(out, " ]");
271 let _ = writeln!(out, " end");
272 let _ = writeln!(out, "end");
273 out
274}
275
276#[allow(clippy::too_many_arguments)]
277fn render_test_file(
278 category: &str,
279 fixtures: &[&Fixture],
280 e2e_config: &E2eConfig,
281 module_path: &str,
282 function_name: &str,
283 result_var: &str,
284 args: &[crate::config::ArgMapping],
285 field_resolver: &FieldResolver,
286 options_type: Option<&str>,
287 options_default_fn: Option<&str>,
288 enum_fields: &HashMap<String, String>,
289 handle_struct_type: Option<&str>,
290 handle_atom_list_fields: &std::collections::HashSet<String>,
291) -> String {
292 let mut out = String::new();
293 out.push_str(&hash::header(CommentStyle::Hash));
294 let _ = writeln!(out, "# E2e tests for category: {category}");
295 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
296
297 let has_http = fixtures.iter().any(|f| f.is_http_test());
299
300 let async_flag = if has_http { "true" } else { "false" };
303 let _ = writeln!(out, " use ExUnit.Case, async: {async_flag}");
304
305 if has_http {
306 let _ = writeln!(out);
307 let _ = writeln!(out, " defp mock_server_url do");
308 let _ = writeln!(
309 out,
310 " System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
311 );
312 let _ = writeln!(out, " end");
313 }
314
315 let has_array_contains = fixtures.iter().any(|fixture| {
318 fixture.assertions.iter().any(|a| {
319 matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
320 && a.field
321 .as_deref()
322 .is_some_and(|f| !f.is_empty() && field_resolver.is_array(field_resolver.resolve(f)))
323 })
324 });
325 if has_array_contains {
326 let _ = writeln!(out);
327 let _ = writeln!(out, " defp alef_e2e_item_texts(item) when is_binary(item), do: [item]");
328 let _ = writeln!(out, " defp alef_e2e_item_texts(item) do");
329 let _ = writeln!(out, " [:kind, :name, :signature, :path, :alias, :text, :source]");
330 let _ = writeln!(out, " |> Enum.filter(&Map.has_key?(item, &1))");
331 let _ = writeln!(out, " |> Enum.flat_map(fn attr ->");
332 let _ = writeln!(out, " case Map.get(item, attr) do");
333 let _ = writeln!(out, " nil -> []");
334 let _ = writeln!(
335 out,
336 " atom when is_atom(atom) -> [atom |> to_string() |> String.capitalize()]"
337 );
338 let _ = writeln!(out, " str -> [to_string(str)]");
339 let _ = writeln!(out, " end");
340 let _ = writeln!(out, " end)");
341 let _ = writeln!(out, " end");
342 }
343
344 let _ = writeln!(out);
345
346 for (i, fixture) in fixtures.iter().enumerate() {
347 if let Some(http) = &fixture.http {
348 render_http_test_case(&mut out, fixture, http);
349 } else {
350 render_test_case(
351 &mut out,
352 fixture,
353 e2e_config,
354 module_path,
355 function_name,
356 result_var,
357 args,
358 field_resolver,
359 options_type,
360 options_default_fn,
361 enum_fields,
362 handle_struct_type,
363 handle_atom_list_fields,
364 );
365 }
366 if i + 1 < fixtures.len() {
367 let _ = writeln!(out);
368 }
369 }
370
371 let _ = writeln!(out, "end");
372 out
373}
374
375const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
382
383const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
386
387struct ElixirTestClientRenderer<'a> {
391 fixture_id: &'a str,
394 expected_status: u16,
396}
397
398impl<'a> client::TestClientRenderer for ElixirTestClientRenderer<'a> {
399 fn language_name(&self) -> &'static str {
400 "elixir"
401 }
402
403 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
409 let escaped_description = description.replace('"', "\\\"");
410 let _ = writeln!(out, " describe \"{fn_name}\" do");
411 if skip_reason.is_some() {
412 let _ = writeln!(out, " @tag :skip");
413 }
414 let _ = writeln!(out, " test \"{escaped_description}\" do");
415 }
416
417 fn render_test_close(&self, out: &mut String) {
419 let _ = writeln!(out, " end");
420 let _ = writeln!(out, " end");
421 }
422
423 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
425 let method = ctx.method.to_lowercase();
426 let mut opts: Vec<String> = Vec::new();
427
428 if let Some(body) = ctx.body {
429 let elixir_val = json_to_elixir(body);
430 opts.push(format!("json: {elixir_val}"));
431 }
432
433 if !ctx.headers.is_empty() {
434 let header_pairs: Vec<String> = ctx
435 .headers
436 .iter()
437 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
438 .collect();
439 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
440 }
441
442 if !ctx.cookies.is_empty() {
443 let cookie_str = ctx
444 .cookies
445 .iter()
446 .map(|(k, v)| format!("{k}={v}"))
447 .collect::<Vec<_>>()
448 .join("; ");
449 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
450 }
451
452 if !ctx.query_params.is_empty() {
453 let pairs: Vec<String> = ctx
454 .query_params
455 .iter()
456 .map(|(k, v)| {
457 let val_str = match v {
458 serde_json::Value::String(s) => s.clone(),
459 other => other.to_string(),
460 };
461 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
462 })
463 .collect();
464 opts.push(format!("params: [{}]", pairs.join(", ")));
465 }
466
467 if (300..400).contains(&self.expected_status) {
470 opts.push("redirect: false".to_string());
471 }
472
473 let fixture_id = escape_elixir(self.fixture_id);
474 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{fixture_id}\"");
475
476 if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
477 if opts.is_empty() {
478 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
479 } else {
480 let opts_str = opts.join(", ");
481 let _ = writeln!(
482 out,
483 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
484 );
485 }
486 } else {
487 opts.insert(0, format!("method: :{method}"));
488 opts.insert(1, format!("url: {url_expr}"));
489 let opts_str = opts.join(", ");
490 let _ = writeln!(out, " {{:ok, response}} = Req.request({opts_str})");
491 }
492 }
493
494 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
495 let _ = writeln!(out, " assert {response_var}.status == {status}");
496 }
497
498 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
503 let header_key = name.to_lowercase();
504 if header_key == "connection" {
506 return;
507 }
508 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
509 let get_header_expr = format!(
510 "Enum.find_value({response_var}.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
511 );
512 match expected {
513 "<<present>>" => {
514 let _ = writeln!(out, " assert {get_header_expr} != nil");
515 }
516 "<<absent>>" => {
517 let _ = writeln!(out, " assert {get_header_expr} == nil");
518 }
519 "<<uuid>>" => {
520 let var = sanitize_ident(&header_key);
521 let _ = writeln!(out, " header_val_{var} = {get_header_expr}");
522 let _ = writeln!(
523 out,
524 " 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}))"
525 );
526 }
527 literal => {
528 let val_lit = format!("\"{}\"", escape_elixir(literal));
529 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
530 }
531 }
532 }
533
534 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
539 let elixir_val = json_to_elixir(expected);
540 match expected {
541 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
542 let _ = writeln!(
543 out,
544 " body_decoded = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
545 );
546 let _ = writeln!(out, " assert body_decoded == {elixir_val}");
547 }
548 _ => {
549 let _ = writeln!(out, " assert {response_var}.body == {elixir_val}");
550 }
551 }
552 }
553
554 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
556 if let Some(obj) = expected.as_object() {
557 let _ = writeln!(
558 out,
559 " decoded_body = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
560 );
561 for (key, val) in obj {
562 let key_lit = format!("\"{}\"", escape_elixir(key));
563 let elixir_val = json_to_elixir(val);
564 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
565 }
566 }
567 }
568
569 fn render_assert_validation_errors(
572 &self,
573 out: &mut String,
574 response_var: &str,
575 errors: &[ValidationErrorExpectation],
576 ) {
577 for err in errors {
578 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
579 let _ = writeln!(
580 out,
581 " assert String.contains?(Jason.encode!({response_var}.body), {msg_lit})"
582 );
583 }
584 }
585}
586
587fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
593 let method = http.request.method.to_uppercase();
594
595 if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
599 let test_name = sanitize_ident(&fixture.id);
600 let test_label = fixture.id.replace('"', "\\\"");
601 let path = &http.request.path;
602 let _ = writeln!(out, " describe \"{test_name}\" do");
603 let _ = writeln!(out, " @tag :skip");
604 let _ = writeln!(out, " test \"{method} {path} - {test_label}\" do");
605 let _ = writeln!(out, " end");
606 let _ = writeln!(out, " end");
607 return;
608 }
609
610 let renderer = ElixirTestClientRenderer {
611 fixture_id: &fixture.id,
612 expected_status: http.expected_response.status_code,
613 };
614 client::http_call::render_http_test(out, &renderer, fixture);
615}
616
617#[allow(clippy::too_many_arguments)]
622fn render_test_case(
623 out: &mut String,
624 fixture: &Fixture,
625 e2e_config: &E2eConfig,
626 default_module_path: &str,
627 default_function_name: &str,
628 default_result_var: &str,
629 args: &[crate::config::ArgMapping],
630 field_resolver: &FieldResolver,
631 options_type: Option<&str>,
632 options_default_fn: Option<&str>,
633 enum_fields: &HashMap<String, String>,
634 handle_struct_type: Option<&str>,
635 handle_atom_list_fields: &std::collections::HashSet<String>,
636) {
637 let test_name = sanitize_ident(&fixture.id);
638 let test_label = fixture.id.replace('"', "\\\"");
639
640 if fixture.mock_response.is_none() && !fixture_has_elixir_callable(fixture, e2e_config) {
646 let _ = writeln!(out, " describe \"{test_name}\" do");
647 let _ = writeln!(out, " @tag :skip");
648 let _ = writeln!(out, " test \"{test_label}\" do");
649 let _ = writeln!(
650 out,
651 " # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
652 );
653 let _ = writeln!(out, " :ok");
654 let _ = writeln!(out, " end");
655 let _ = writeln!(out, " end");
656 return;
657 }
658
659 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
661 let lang = "elixir";
662 let call_overrides = call_config.overrides.get(lang);
663
664 let base_fn = call_overrides
667 .and_then(|o| o.function.as_ref())
668 .cloned()
669 .unwrap_or_else(|| call_config.function.clone());
670 if base_fn.starts_with("batch_extract_") {
671 let _ = writeln!(
672 out,
673 " describe \"{test_name}\" do",
674 test_name = sanitize_ident(&fixture.id)
675 );
676 let _ = writeln!(out, " @tag :skip");
677 let _ = writeln!(
678 out,
679 " test \"{test_label}\" do",
680 test_label = fixture.id.replace('"', "\\\"")
681 );
682 let _ = writeln!(
683 out,
684 " # batch functions excluded from Elixir binding: unsafe NIF tuple marshalling"
685 );
686 let _ = writeln!(out, " :ok");
687 let _ = writeln!(out, " end");
688 let _ = writeln!(out, " end");
689 return;
690 }
691
692 let (module_path, function_name, result_var) = if fixture.call.is_some() {
695 let raw_module = call_overrides
696 .and_then(|o| o.module.as_ref())
697 .cloned()
698 .unwrap_or_else(|| call_config.module.clone());
699 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
700 {
701 raw_module.clone()
702 } else {
703 elixir_module_name(&raw_module)
704 };
705 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") && !base_fn.ends_with("_stream") {
706 format!("{base_fn}_async")
707 } else {
708 base_fn
709 };
710 (resolved_module, resolved_fn, call_config.result_var.clone())
711 } else {
712 (
713 default_module_path.to_string(),
714 default_function_name.to_string(),
715 default_result_var.to_string(),
716 )
717 };
718
719 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
720
721 let (
723 effective_args,
724 effective_options_type,
725 effective_options_default_fn,
726 effective_enum_fields,
727 effective_handle_struct_type,
728 effective_handle_atom_list_fields,
729 );
730 let empty_enum_fields_local: HashMap<String, String>;
731 let empty_atom_fields_local: std::collections::HashSet<String>;
732 let (
733 resolved_args,
734 resolved_options_type,
735 resolved_options_default_fn,
736 resolved_enum_fields_ref,
737 resolved_handle_struct_type,
738 resolved_handle_atom_list_fields_ref,
739 ) = if fixture.call.is_some() {
740 let co = call_config.overrides.get(lang);
741 effective_args = call_config.args.as_slice();
742 effective_options_type = co.and_then(|o| o.options_type.as_deref());
743 effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
744 empty_enum_fields_local = HashMap::new();
745 effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
746 effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
747 empty_atom_fields_local = std::collections::HashSet::new();
748 effective_handle_atom_list_fields = co
749 .map(|o| &o.handle_atom_list_fields)
750 .unwrap_or(&empty_atom_fields_local);
751 (
752 effective_args,
753 effective_options_type,
754 effective_options_default_fn,
755 effective_enum_fields,
756 effective_handle_struct_type,
757 effective_handle_atom_list_fields,
758 )
759 } else {
760 (
761 args as &[_],
762 options_type,
763 options_default_fn,
764 enum_fields,
765 handle_struct_type,
766 handle_atom_list_fields,
767 )
768 };
769
770 let (mut setup_lines, args_str) = build_args_and_setup(
771 &fixture.input,
772 resolved_args,
773 &module_path,
774 resolved_options_type,
775 resolved_options_default_fn,
776 resolved_enum_fields_ref,
777 &fixture.id,
778 resolved_handle_struct_type,
779 resolved_handle_atom_list_fields_ref,
780 );
781
782 let visitor_var = fixture
784 .visitor
785 .as_ref()
786 .map(|visitor_spec| build_elixir_visitor(&mut setup_lines, visitor_spec));
787
788 let final_args = if let Some(ref visitor_var) = visitor_var {
791 let parts: Vec<&str> = args_str.split(", ").collect();
795 if parts.len() == 2 && parts[1] == "nil" {
796 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
798 } else if parts.len() == 2 {
799 setup_lines.push(format!(
801 "{} = Map.put({}, :visitor, {})",
802 parts[1], parts[1], visitor_var
803 ));
804 args_str
805 } else if parts.len() == 1 {
806 format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
808 } else {
809 args_str
810 }
811 } else {
812 args_str
813 };
814
815 let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
817 e2e_config
818 .call
819 .overrides
820 .get("elixir")
821 .and_then(|o| o.client_factory.as_deref())
822 });
823
824 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
828 let final_args_with_extras = if extra_args.is_empty() {
829 final_args
830 } else if final_args.is_empty() {
831 extra_args.join(", ")
832 } else {
833 format!("{final_args}, {}", extra_args.join(", "))
834 };
835
836 let effective_args = if client_factory.is_some() {
838 if final_args_with_extras.is_empty() {
839 "client".to_string()
840 } else {
841 format!("client, {final_args_with_extras}")
842 }
843 } else {
844 final_args_with_extras
845 };
846
847 let needs_api_key_skip = fixture.mock_response.is_none()
851 && fixture.http.is_none()
852 && fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()).is_some();
853
854 let _ = writeln!(out, " describe \"{test_name}\" do");
855 let _ = writeln!(out, " test \"{test_label}\" do");
856
857 if needs_api_key_skip {
858 let api_key_var = fixture
859 .env
860 .as_ref()
861 .and_then(|e| e.api_key_var.as_deref())
862 .unwrap_or("");
863 let _ = writeln!(out, " if System.get_env(\"{api_key_var}\") in [nil, \"\"] do");
864 let _ = writeln!(out, " # {api_key_var} not set — skipping live smoke test");
865 let _ = writeln!(out, " :ok");
866 let _ = writeln!(out, " else");
867 }
868
869 for line in &setup_lines {
870 let _ = writeln!(out, " {line}");
871 }
872
873 if let Some(factory) = client_factory {
875 let fixture_id = &fixture.id;
876 let _ = writeln!(
877 out,
878 " {{:ok, client}} = {module_path}.{factory}(\"test-key\", (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
879 );
880 }
881
882 let returns_result = call_overrides
884 .and_then(|o| o.returns_result)
885 .unwrap_or(call_config.returns_result || client_factory.is_some());
886
887 let result_is_simple = call_config.result_is_simple || call_overrides.is_some_and(|o| o.result_is_simple);
892
893 if expects_error {
894 if returns_result {
895 let _ = writeln!(
896 out,
897 " assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
898 );
899 } else {
900 let _ = writeln!(out, " _result = {module_path}.{function_name}({effective_args})");
902 }
903 if needs_api_key_skip {
904 let _ = writeln!(out, " end");
905 }
906 let _ = writeln!(out, " end");
907 let _ = writeln!(out, " end");
908 return;
909 }
910
911 if returns_result {
912 let _ = writeln!(
913 out,
914 " {{:ok, {result_var}}} = {module_path}.{function_name}({effective_args})"
915 );
916 } else {
917 let _ = writeln!(
919 out,
920 " {result_var} = {module_path}.{function_name}({effective_args})"
921 );
922 }
923
924 for assertion in &fixture.assertions {
925 render_assertion(
926 out,
927 assertion,
928 &result_var,
929 field_resolver,
930 &module_path,
931 &e2e_config.fields_enum,
932 result_is_simple,
933 );
934 }
935
936 if needs_api_key_skip {
937 let _ = writeln!(out, " end");
938 }
939 let _ = writeln!(out, " end");
940 let _ = writeln!(out, " end");
941}
942
943#[allow(clippy::too_many_arguments)]
947fn emit_elixir_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
949 if let Some(items) = arr.as_array() {
950 let item_strs: Vec<String> = items
951 .iter()
952 .filter_map(|item| {
953 if let Some(obj) = item.as_object() {
954 match elem_type {
955 "BatchBytesItem" => {
956 let content = obj.get("content").and_then(|v| v.as_array());
957 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
958 let content_code = if let Some(arr) = content {
959 let bytes: Vec<String> =
960 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
961 format!("<<{}>>", bytes.join(", "))
962 } else {
963 "<<>>".to_string()
964 };
965 Some(format!(
966 "%BatchBytesItem{{content: {}, mime_type: \"{}\"}}",
967 content_code, mime_type
968 ))
969 }
970 "BatchFileItem" => {
971 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
972 Some(format!("%BatchFileItem{{path: \"{}\"}}", path))
973 }
974 _ => None,
975 }
976 } else {
977 None
978 }
979 })
980 .collect();
981 format!("[{}]", item_strs.join(", "))
982 } else {
983 "[]".to_string()
984 }
985}
986
987#[allow(clippy::too_many_arguments)]
988fn build_args_and_setup(
989 input: &serde_json::Value,
990 args: &[crate::config::ArgMapping],
991 module_path: &str,
992 options_type: Option<&str>,
993 options_default_fn: Option<&str>,
994 enum_fields: &HashMap<String, String>,
995 fixture_id: &str,
996 _handle_struct_type: Option<&str>,
997 _handle_atom_list_fields: &std::collections::HashSet<String>,
998) -> (Vec<String>, String) {
999 if args.is_empty() {
1000 let is_empty_input = match input {
1004 serde_json::Value::Null => true,
1005 serde_json::Value::Object(m) => m.is_empty(),
1006 _ => false,
1007 };
1008 if is_empty_input {
1009 return (Vec::new(), String::new());
1010 }
1011 return (Vec::new(), json_to_elixir(input));
1012 }
1013
1014 let mut setup_lines: Vec<String> = Vec::new();
1015 let mut parts: Vec<String> = Vec::new();
1016
1017 for arg in args {
1018 if arg.arg_type == "mock_url" {
1019 setup_lines.push(format!(
1020 "{} = (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
1021 arg.name,
1022 ));
1023 parts.push(arg.name.clone());
1024 continue;
1025 }
1026
1027 if arg.arg_type == "handle" {
1028 let constructor_name = format!("create_{}", arg.name.to_snake_case());
1032 let config_value = if arg.field == "input" {
1033 input
1034 } else {
1035 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1036 input.get(field).unwrap_or(&serde_json::Value::Null)
1037 };
1038 let name = &arg.name;
1039 if config_value.is_null()
1040 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1041 {
1042 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
1043 } else {
1044 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
1047 let escaped = escape_elixir(&json_str);
1048 setup_lines.push(format!("{name}_config = \"{escaped}\""));
1049 setup_lines.push(format!(
1050 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
1051 ));
1052 }
1053 parts.push(arg.name.clone());
1054 continue;
1055 }
1056
1057 let val = if arg.field == "input" {
1058 Some(input)
1059 } else {
1060 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1061 input.get(field)
1062 };
1063 match val {
1064 None | Some(serde_json::Value::Null) if arg.optional => {
1065 parts.push("nil".to_string());
1068 continue;
1069 }
1070 None | Some(serde_json::Value::Null) => {
1071 let default_val = match arg.arg_type.as_str() {
1073 "string" => "\"\"".to_string(),
1074 "int" | "integer" => "0".to_string(),
1075 "float" | "number" => "0.0".to_string(),
1076 "bool" | "boolean" => "false".to_string(),
1077 _ => "nil".to_string(),
1078 };
1079 parts.push(default_val);
1080 }
1081 Some(v) => {
1082 if arg.arg_type == "file_path" {
1085 if let Some(path_str) = v.as_str() {
1086 let full_path = format!("../../test_documents/{path_str}");
1087 parts.push(format!("\"{}\"", escape_elixir(&full_path)));
1088 continue;
1089 }
1090 }
1091 if arg.arg_type == "bytes" {
1094 if let Some(raw) = v.as_str() {
1095 let var_name = &arg.name;
1096 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
1097 parts.push(format!("\"{}\"", escape_elixir(raw)));
1099 } else {
1100 let first = raw.chars().next().unwrap_or('\0');
1101 let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
1102 && raw
1103 .find('/')
1104 .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
1105 if is_file_path {
1106 let full_path = format!("../../test_documents/{raw}");
1108 let escaped = escape_elixir(&full_path);
1109 setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
1110 parts.push(var_name.to_string());
1111 } else {
1112 setup_lines.push(format!(
1114 "{var_name} = Base.decode64!(\"{}\", padding: false)",
1115 escape_elixir(raw)
1116 ));
1117 parts.push(var_name.to_string());
1118 }
1119 }
1120 continue;
1121 }
1122 }
1123 if arg.arg_type == "json_object" && !v.is_null() {
1125 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
1126 (options_type, options_default_fn, v.as_object())
1127 {
1128 let options_var = "options";
1130 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
1131
1132 for (k, vv) in obj.iter() {
1134 let snake_key = k.to_snake_case();
1135 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1136 if let Some(s) = vv.as_str() {
1137 let snake_val = s.to_snake_case();
1138 format!(":{snake_val}")
1140 } else {
1141 json_to_elixir(vv)
1142 }
1143 } else {
1144 json_to_elixir(vv)
1145 };
1146 setup_lines.push(format!(
1147 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
1148 ));
1149 }
1150
1151 parts.push(options_var.to_string());
1153 continue;
1154 }
1155 if let Some(elem_type) = &arg.element_type {
1157 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1158 parts.push(emit_elixir_batch_item_array(v, elem_type));
1159 continue;
1160 }
1161 if v.is_array() {
1164 parts.push(json_to_elixir(v));
1165 continue;
1166 }
1167 }
1168 if !v.is_null() {
1172 let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
1173 let escaped = escape_elixir(&json_str);
1174 parts.push(format!("\"{escaped}\""));
1175 continue;
1176 }
1177 }
1178 parts.push(json_to_elixir(v));
1179 }
1180 }
1181 }
1182
1183 (setup_lines, parts.join(", "))
1184}
1185
1186fn is_numeric_expr(field_expr: &str) -> bool {
1189 field_expr.starts_with("length(")
1190}
1191
1192fn render_assertion(
1193 out: &mut String,
1194 assertion: &Assertion,
1195 result_var: &str,
1196 field_resolver: &FieldResolver,
1197 module_path: &str,
1198 fields_enum: &std::collections::HashSet<String>,
1199 result_is_simple: bool,
1200) {
1201 if let Some(f) = &assertion.field {
1204 match f.as_str() {
1205 "chunks_have_content" => {
1206 let pred =
1207 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
1208 match assertion.assertion_type.as_str() {
1209 "is_true" => {
1210 let _ = writeln!(out, " assert {pred}");
1211 }
1212 "is_false" => {
1213 let _ = writeln!(out, " refute {pred}");
1214 }
1215 _ => {
1216 let _ = writeln!(
1217 out,
1218 " # skipped: unsupported assertion type on synthetic field '{f}'"
1219 );
1220 }
1221 }
1222 return;
1223 }
1224 "chunks_have_embeddings" => {
1225 let pred = format!(
1226 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
1227 );
1228 match assertion.assertion_type.as_str() {
1229 "is_true" => {
1230 let _ = writeln!(out, " assert {pred}");
1231 }
1232 "is_false" => {
1233 let _ = writeln!(out, " refute {pred}");
1234 }
1235 _ => {
1236 let _ = writeln!(
1237 out,
1238 " # skipped: unsupported assertion type on synthetic field '{f}'"
1239 );
1240 }
1241 }
1242 return;
1243 }
1244 "embeddings" => {
1248 match assertion.assertion_type.as_str() {
1249 "count_equals" => {
1250 if let Some(val) = &assertion.value {
1251 let ex_val = json_to_elixir(val);
1252 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
1253 }
1254 }
1255 "count_min" => {
1256 if let Some(val) = &assertion.value {
1257 let ex_val = json_to_elixir(val);
1258 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
1259 }
1260 }
1261 "not_empty" => {
1262 let _ = writeln!(out, " assert {result_var} != []");
1263 }
1264 "is_empty" => {
1265 let _ = writeln!(out, " assert {result_var} == []");
1266 }
1267 _ => {
1268 let _ = writeln!(
1269 out,
1270 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1271 );
1272 }
1273 }
1274 return;
1275 }
1276 "embedding_dimensions" => {
1277 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1278 match assertion.assertion_type.as_str() {
1279 "equals" => {
1280 if let Some(val) = &assertion.value {
1281 let ex_val = json_to_elixir(val);
1282 let _ = writeln!(out, " assert {expr} == {ex_val}");
1283 }
1284 }
1285 "greater_than" => {
1286 if let Some(val) = &assertion.value {
1287 let ex_val = json_to_elixir(val);
1288 let _ = writeln!(out, " assert {expr} > {ex_val}");
1289 }
1290 }
1291 _ => {
1292 let _ = writeln!(
1293 out,
1294 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1295 );
1296 }
1297 }
1298 return;
1299 }
1300 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1301 let pred = match f.as_str() {
1302 "embeddings_valid" => {
1303 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1304 }
1305 "embeddings_finite" => {
1306 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1307 }
1308 "embeddings_non_zero" => {
1309 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1310 }
1311 "embeddings_normalized" => {
1312 format!(
1313 "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)"
1314 )
1315 }
1316 _ => unreachable!(),
1317 };
1318 match assertion.assertion_type.as_str() {
1319 "is_true" => {
1320 let _ = writeln!(out, " assert {pred}");
1321 }
1322 "is_false" => {
1323 let _ = writeln!(out, " refute {pred}");
1324 }
1325 _ => {
1326 let _ = writeln!(
1327 out,
1328 " # skipped: unsupported assertion type on synthetic field '{f}'"
1329 );
1330 }
1331 }
1332 return;
1333 }
1334 "keywords" | "keywords_count" => {
1337 let _ = writeln!(
1338 out,
1339 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1340 );
1341 return;
1342 }
1343 _ => {}
1344 }
1345 }
1346
1347 if !result_is_simple {
1352 if let Some(f) = &assertion.field {
1353 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1354 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1355 return;
1356 }
1357 }
1358 }
1359
1360 let field_expr = if result_is_simple {
1364 result_var.to_string()
1365 } else {
1366 match &assertion.field {
1367 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1368 _ => result_var.to_string(),
1369 }
1370 };
1371
1372 let is_numeric = is_numeric_expr(&field_expr);
1375 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1379 let resolved = field_resolver.resolve(f);
1380 fields_enum.contains(f) || fields_enum.contains(resolved)
1381 });
1382 let coerced_field_expr = if field_is_enum {
1383 format!("to_string({field_expr})")
1384 } else {
1385 field_expr.clone()
1386 };
1387 let trimmed_field_expr = if is_numeric {
1388 field_expr.clone()
1389 } else {
1390 format!("String.trim({coerced_field_expr})")
1391 };
1392
1393 let field_is_array = assertion
1396 .field
1397 .as_deref()
1398 .filter(|f| !f.is_empty())
1399 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1400
1401 match assertion.assertion_type.as_str() {
1402 "equals" => {
1403 if let Some(expected) = &assertion.value {
1404 let elixir_val = json_to_elixir(expected);
1405 let is_string_expected = expected.is_string();
1407 if is_string_expected && !is_numeric {
1408 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1409 } else if field_is_enum {
1410 let _ = writeln!(out, " assert {coerced_field_expr} == {elixir_val}");
1411 } else {
1412 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1413 }
1414 }
1415 }
1416 "contains" => {
1417 if let Some(expected) = &assertion.value {
1418 let elixir_val = json_to_elixir(expected);
1419 if field_is_array && expected.is_string() {
1420 let _ = writeln!(
1422 out,
1423 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1424 );
1425 } else {
1426 let _ = writeln!(
1428 out,
1429 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1430 );
1431 }
1432 }
1433 }
1434 "contains_all" => {
1435 if let Some(values) = &assertion.values {
1436 for val in values {
1437 let elixir_val = json_to_elixir(val);
1438 if field_is_array && val.is_string() {
1439 let _ = writeln!(
1440 out,
1441 " assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1442 );
1443 } else {
1444 let _ = writeln!(
1445 out,
1446 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1447 );
1448 }
1449 }
1450 }
1451 }
1452 "not_contains" => {
1453 if let Some(expected) = &assertion.value {
1454 let elixir_val = json_to_elixir(expected);
1455 if field_is_array && expected.is_string() {
1456 let _ = writeln!(
1457 out,
1458 " refute Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1459 );
1460 } else {
1461 let _ = writeln!(
1462 out,
1463 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1464 );
1465 }
1466 }
1467 }
1468 "not_empty" => {
1469 let _ = writeln!(out, " assert {field_expr} != \"\"");
1470 }
1471 "is_empty" => {
1472 if is_numeric {
1473 let _ = writeln!(out, " assert {field_expr} == 0");
1475 } else {
1476 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1478 }
1479 }
1480 "contains_any" => {
1481 if let Some(values) = &assertion.values {
1482 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1483 let list_str = items.join(", ");
1484 let _ = writeln!(
1485 out,
1486 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1487 );
1488 }
1489 }
1490 "greater_than" => {
1491 if let Some(val) = &assertion.value {
1492 let elixir_val = json_to_elixir(val);
1493 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
1494 }
1495 }
1496 "less_than" => {
1497 if let Some(val) = &assertion.value {
1498 let elixir_val = json_to_elixir(val);
1499 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
1500 }
1501 }
1502 "greater_than_or_equal" => {
1503 if let Some(val) = &assertion.value {
1504 let elixir_val = json_to_elixir(val);
1505 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
1506 }
1507 }
1508 "less_than_or_equal" => {
1509 if let Some(val) = &assertion.value {
1510 let elixir_val = json_to_elixir(val);
1511 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
1512 }
1513 }
1514 "starts_with" => {
1515 if let Some(expected) = &assertion.value {
1516 let elixir_val = json_to_elixir(expected);
1517 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
1518 }
1519 }
1520 "ends_with" => {
1521 if let Some(expected) = &assertion.value {
1522 let elixir_val = json_to_elixir(expected);
1523 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
1524 }
1525 }
1526 "min_length" => {
1527 if let Some(val) = &assertion.value {
1528 if let Some(n) = val.as_u64() {
1529 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1530 }
1531 }
1532 }
1533 "max_length" => {
1534 if let Some(val) = &assertion.value {
1535 if let Some(n) = val.as_u64() {
1536 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
1537 }
1538 }
1539 }
1540 "count_min" => {
1541 if let Some(val) = &assertion.value {
1542 if let Some(n) = val.as_u64() {
1543 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
1544 }
1545 }
1546 }
1547 "count_equals" => {
1548 if let Some(val) = &assertion.value {
1549 if let Some(n) = val.as_u64() {
1550 let _ = writeln!(out, " assert length({field_expr}) == {n}");
1551 }
1552 }
1553 }
1554 "is_true" => {
1555 let _ = writeln!(out, " assert {field_expr} == true");
1556 }
1557 "is_false" => {
1558 let _ = writeln!(out, " assert {field_expr} == false");
1559 }
1560 "method_result" => {
1561 if let Some(method_name) = &assertion.method {
1562 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1563 let check = assertion.check.as_deref().unwrap_or("is_true");
1564 match check {
1565 "equals" => {
1566 if let Some(val) = &assertion.value {
1567 let elixir_val = json_to_elixir(val);
1568 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
1569 }
1570 }
1571 "is_true" => {
1572 let _ = writeln!(out, " assert {call_expr} == true");
1573 }
1574 "is_false" => {
1575 let _ = writeln!(out, " assert {call_expr} == false");
1576 }
1577 "greater_than_or_equal" => {
1578 if let Some(val) = &assertion.value {
1579 let n = val.as_u64().unwrap_or(0);
1580 let _ = writeln!(out, " assert {call_expr} >= {n}");
1581 }
1582 }
1583 "count_min" => {
1584 if let Some(val) = &assertion.value {
1585 let n = val.as_u64().unwrap_or(0);
1586 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
1587 }
1588 }
1589 "contains" => {
1590 if let Some(val) = &assertion.value {
1591 let elixir_val = json_to_elixir(val);
1592 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
1593 }
1594 }
1595 "is_error" => {
1596 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
1597 }
1598 other_check => {
1599 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1600 }
1601 }
1602 } else {
1603 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1604 }
1605 }
1606 "matches_regex" => {
1607 if let Some(expected) = &assertion.value {
1608 let elixir_val = json_to_elixir(expected);
1609 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1610 }
1611 }
1612 "not_error" => {
1613 }
1615 "error" => {
1616 }
1618 other => {
1619 panic!("Elixir e2e generator: unsupported assertion type: {other}");
1620 }
1621 }
1622}
1623
1624fn build_elixir_method_call(
1627 result_var: &str,
1628 method_name: &str,
1629 args: Option<&serde_json::Value>,
1630 module_path: &str,
1631) -> String {
1632 match method_name {
1633 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1634 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1635 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1636 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1637 "contains_node_type" => {
1638 let node_type = args
1639 .and_then(|a| a.get("node_type"))
1640 .and_then(|v| v.as_str())
1641 .unwrap_or("");
1642 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1643 }
1644 "find_nodes_by_type" => {
1645 let node_type = args
1646 .and_then(|a| a.get("node_type"))
1647 .and_then(|v| v.as_str())
1648 .unwrap_or("");
1649 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1650 }
1651 "run_query" => {
1652 let query_source = args
1653 .and_then(|a| a.get("query_source"))
1654 .and_then(|v| v.as_str())
1655 .unwrap_or("");
1656 let language = args
1657 .and_then(|a| a.get("language"))
1658 .and_then(|v| v.as_str())
1659 .unwrap_or("");
1660 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1661 }
1662 _ => format!("{module_path}.{method_name}({result_var})"),
1663 }
1664}
1665
1666fn elixir_module_name(category: &str) -> String {
1668 use heck::ToUpperCamelCase;
1669 category.to_upper_camel_case()
1670}
1671
1672fn json_to_elixir(value: &serde_json::Value) -> String {
1674 match value {
1675 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1676 serde_json::Value::Bool(true) => "true".to_string(),
1677 serde_json::Value::Bool(false) => "false".to_string(),
1678 serde_json::Value::Number(n) => {
1679 let s = n.to_string().replace("e+", "e");
1683 if s.contains('e') && !s.contains('.') {
1684 s.replacen('e', ".0e", 1)
1686 } else {
1687 s
1688 }
1689 }
1690 serde_json::Value::Null => "nil".to_string(),
1691 serde_json::Value::Array(arr) => {
1692 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1693 format!("[{}]", items.join(", "))
1694 }
1695 serde_json::Value::Object(map) => {
1696 let entries: Vec<String> = map
1697 .iter()
1698 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1699 .collect();
1700 format!("%{{{}}}", entries.join(", "))
1701 }
1702 }
1703}
1704
1705fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1707 use std::fmt::Write as FmtWrite;
1708 let mut visitor_obj = String::new();
1709 let _ = writeln!(visitor_obj, "%{{");
1710 for (method_name, action) in &visitor_spec.callbacks {
1711 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1712 }
1713 let _ = writeln!(visitor_obj, " }}");
1714
1715 setup_lines.push(format!("visitor = {visitor_obj}"));
1716 "visitor".to_string()
1717}
1718
1719fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1721 use std::fmt::Write as FmtWrite;
1722
1723 let handle_method = format!("handle_{}", &method_name[6..]); let arg_binding = match action {
1733 CallbackAction::CustomTemplate { .. } => "args",
1734 _ => "_args",
1735 };
1736 let _ = writeln!(out, " :{handle_method} => fn({arg_binding}) ->");
1737 match action {
1738 CallbackAction::Skip => {
1739 let _ = writeln!(out, " :skip");
1740 }
1741 CallbackAction::Continue => {
1742 let _ = writeln!(out, " :continue");
1743 }
1744 CallbackAction::PreserveHtml => {
1745 let _ = writeln!(out, " :preserve_html");
1746 }
1747 CallbackAction::Custom { output } => {
1748 let escaped = escape_elixir(output);
1749 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1750 }
1751 CallbackAction::CustomTemplate { template } => {
1752 let expr = template_to_elixir_concat(template);
1756 let _ = writeln!(out, " {{:custom, {expr}}}");
1757 }
1758 }
1759 let _ = writeln!(out, " end,");
1760}
1761
1762fn template_to_elixir_concat(template: &str) -> String {
1767 let mut parts: Vec<String> = Vec::new();
1768 let mut static_buf = String::new();
1769 let mut chars = template.chars().peekable();
1770
1771 while let Some(ch) = chars.next() {
1772 if ch == '{' {
1773 let mut key = String::new();
1774 let mut closed = false;
1775 for kc in chars.by_ref() {
1776 if kc == '}' {
1777 closed = true;
1778 break;
1779 }
1780 key.push(kc);
1781 }
1782 if closed && !key.is_empty() {
1783 if !static_buf.is_empty() {
1784 let escaped = escape_elixir(&static_buf);
1785 parts.push(format!("\"{escaped}\""));
1786 static_buf.clear();
1787 }
1788 let escaped_key = escape_elixir(&key);
1789 parts.push(format!("Map.get(args, \"{escaped_key}\", \"\")"));
1790 } else {
1791 static_buf.push('{');
1792 static_buf.push_str(&key);
1793 if !closed {
1794 }
1796 }
1797 } else {
1798 static_buf.push(ch);
1799 }
1800 }
1801
1802 if !static_buf.is_empty() {
1803 let escaped = escape_elixir(&static_buf);
1804 parts.push(format!("\"{escaped}\""));
1805 }
1806
1807 if parts.is_empty() {
1808 return "\"\"".to_string();
1809 }
1810 parts.join(" <> ")
1811}
1812
1813fn fixture_has_elixir_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
1814 if fixture.is_http_test() {
1816 return false;
1817 }
1818 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1819 let elixir_override = call_config
1820 .overrides
1821 .get("elixir")
1822 .or_else(|| e2e_config.call.overrides.get("elixir"));
1823 if elixir_override.and_then(|o| o.client_factory.as_deref()).is_some() {
1825 return true;
1826 }
1827 let function_from_override = elixir_override.and_then(|o| o.function.as_deref());
1832
1833 function_from_override.is_some() || !call_config.function.is_empty()
1835}