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::AlefConfig;
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 alef_config: &AlefConfig,
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 = if call.r#async && !base_function_name.ends_with("_async") {
57 format!("{base_function_name}_async")
58 } else {
59 base_function_name
60 };
61 let options_type = overrides.and_then(|o| o.options_type.clone());
62 let options_default_fn = overrides.and_then(|o| o.options_via.clone());
63 let empty_enum_fields = HashMap::new();
64 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
65 let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
66 let empty_atom_fields = std::collections::HashSet::new();
67 let handle_atom_list_fields = overrides
68 .map(|o| &o.handle_atom_list_fields)
69 .unwrap_or(&empty_atom_fields);
70 let result_var = &call.result_var;
71
72 let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
74 let has_nif_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| !f.is_http_test()));
75
76 let pkg_ref = e2e_config.resolve_package(lang);
78 let pkg_path = if has_nif_tests {
79 pkg_ref.as_ref().and_then(|p| p.path.as_deref()).unwrap_or("")
80 } else {
81 ""
82 };
83
84 let pkg_atom = alef_config.elixir_app_name();
93 files.push(GeneratedFile {
94 path: output_base.join("mix.exs"),
95 content: render_mix_exs(&pkg_atom, pkg_path, e2e_config.dep_mode, has_http_tests, has_nif_tests),
96 generated_header: false,
97 });
98
99 files.push(GeneratedFile {
101 path: output_base.join("lib").join("e2e_elixir.ex"),
102 content: "defmodule E2eElixir do\n @moduledoc false\nend\n".to_string(),
103 generated_header: false,
104 });
105
106 files.push(GeneratedFile {
108 path: output_base.join("test").join("test_helper.exs"),
109 content: render_test_helper(has_http_tests),
110 generated_header: false,
111 });
112
113 for group in groups {
115 let active: Vec<&Fixture> = group
116 .fixtures
117 .iter()
118 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
119 .collect();
120
121 if active.is_empty() {
122 continue;
123 }
124
125 let filename = format!("{}_test.exs", sanitize_filename(&group.category));
126 let field_resolver = FieldResolver::new(
127 &e2e_config.fields,
128 &e2e_config.fields_optional,
129 &e2e_config.result_fields,
130 &e2e_config.fields_array,
131 );
132 let content = render_test_file(
133 &group.category,
134 &active,
135 e2e_config,
136 &module_path,
137 &function_name,
138 result_var,
139 &e2e_config.call.args,
140 &field_resolver,
141 options_type.as_deref(),
142 options_default_fn.as_deref(),
143 enum_fields,
144 handle_struct_type.as_deref(),
145 handle_atom_list_fields,
146 );
147 files.push(GeneratedFile {
148 path: output_base.join("test").join(filename),
149 content,
150 generated_header: true,
151 });
152 }
153
154 Ok(files)
155 }
156
157 fn language_name(&self) -> &'static str {
158 "elixir"
159 }
160}
161
162fn render_test_helper(has_http_tests: bool) -> String {
163 if has_http_tests {
164 r#"ExUnit.start()
165
166# Spawn mock-server binary and set MOCK_SERVER_URL for all tests.
167mock_server_bin = Path.expand("../../rust/target/release/mock-server", __DIR__)
168fixtures_dir = Path.expand("../../../fixtures", __DIR__)
169
170if File.exists?(mock_server_bin) do
171 port = Port.open({:spawn_executable, mock_server_bin}, [
172 :binary,
173 :line,
174 args: [fixtures_dir]
175 ])
176 receive do
177 {^port, {:data, {:eol, "MOCK_SERVER_URL=" <> url}}} ->
178 System.put_env("MOCK_SERVER_URL", url)
179 after
180 30_000 ->
181 raise "mock-server startup timeout"
182 end
183end
184"#
185 .to_string()
186 } else {
187 "ExUnit.start()\n".to_string()
188 }
189}
190
191fn render_mix_exs(
192 pkg_name: &str,
193 pkg_path: &str,
194 dep_mode: crate::config::DependencyMode,
195 has_http_tests: bool,
196 has_nif_tests: bool,
197) -> String {
198 let mut out = String::new();
199 let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
200 let _ = writeln!(out, " use Mix.Project");
201 let _ = writeln!(out);
202 let _ = writeln!(out, " def project do");
203 let _ = writeln!(out, " [");
204 let _ = writeln!(out, " app: :e2e_elixir,");
205 let _ = writeln!(out, " version: \"0.1.0\",");
206 let _ = writeln!(out, " elixir: \"~> 1.14\",");
207 let _ = writeln!(out, " deps: deps()");
208 let _ = writeln!(out, " ]");
209 let _ = writeln!(out, " end");
210 let _ = writeln!(out);
211 let _ = writeln!(out, " defp deps do");
212 let _ = writeln!(out, " [");
213
214 let mut deps: Vec<String> = Vec::new();
216
217 if has_nif_tests && !pkg_path.is_empty() {
219 let pkg_atom = pkg_name;
220 let nif_dep = match dep_mode {
221 crate::config::DependencyMode::Local => {
222 format!(" {{:{pkg_atom}, path: \"{pkg_path}\"}}")
223 }
224 crate::config::DependencyMode::Registry => {
225 format!(" {{:{pkg_atom}, \"{pkg_path}\"}}")
227 }
228 };
229 deps.push(nif_dep);
230 deps.push(format!(
232 " {{:rustler_precompiled, \"{rp}\"}}",
233 rp = tv::hex::RUSTLER_PRECOMPILED
234 ));
235 deps.push(format!(
237 " {{:rustler, \"{rustler}\", optional: true, runtime: false}}",
238 rustler = tv::hex::RUSTLER
239 ));
240 }
241
242 if has_http_tests {
244 deps.push(format!(" {{:req, \"{req}\"}}", req = tv::hex::REQ));
245 deps.push(format!(" {{:jason, \"{jason}\"}}", jason = tv::hex::JASON));
246 }
247
248 let _ = writeln!(out, "{}", deps.join(",\n"));
249 let _ = writeln!(out, " ]");
250 let _ = writeln!(out, " end");
251 let _ = writeln!(out, "end");
252 out
253}
254
255#[allow(clippy::too_many_arguments)]
256fn render_test_file(
257 category: &str,
258 fixtures: &[&Fixture],
259 e2e_config: &E2eConfig,
260 module_path: &str,
261 function_name: &str,
262 result_var: &str,
263 args: &[crate::config::ArgMapping],
264 field_resolver: &FieldResolver,
265 options_type: Option<&str>,
266 options_default_fn: Option<&str>,
267 enum_fields: &HashMap<String, String>,
268 handle_struct_type: Option<&str>,
269 handle_atom_list_fields: &std::collections::HashSet<String>,
270) -> String {
271 let mut out = String::new();
272 out.push_str(&hash::header(CommentStyle::Hash));
273 let _ = writeln!(out, "# E2e tests for category: {category}");
274 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
275
276 let has_http = fixtures.iter().any(|f| f.is_http_test());
278
279 let async_flag = if has_http { "true" } else { "false" };
282 let _ = writeln!(out, " use ExUnit.Case, async: {async_flag}");
283
284 if has_http {
285 let _ = writeln!(out);
286 let _ = writeln!(out, " defp mock_server_url do");
287 let _ = writeln!(
288 out,
289 " System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
290 );
291 let _ = writeln!(out, " end");
292 }
293
294 let _ = writeln!(out);
295
296 for (i, fixture) in fixtures.iter().enumerate() {
297 if let Some(http) = &fixture.http {
298 render_http_test_case(&mut out, fixture, http);
299 } else {
300 render_test_case(
301 &mut out,
302 fixture,
303 e2e_config,
304 module_path,
305 function_name,
306 result_var,
307 args,
308 field_resolver,
309 options_type,
310 options_default_fn,
311 enum_fields,
312 handle_struct_type,
313 handle_atom_list_fields,
314 );
315 }
316 if i + 1 < fixtures.len() {
317 let _ = writeln!(out);
318 }
319 }
320
321 let _ = writeln!(out, "end");
322 out
323}
324
325const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
332
333const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
336
337struct ElixirTestClientRenderer<'a> {
341 fixture_id: &'a str,
344 expected_status: u16,
346}
347
348impl<'a> client::TestClientRenderer for ElixirTestClientRenderer<'a> {
349 fn language_name(&self) -> &'static str {
350 "elixir"
351 }
352
353 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
359 let escaped_description = description.replace('"', "\\\"");
360 let _ = writeln!(out, " describe \"{fn_name}\" do");
361 if skip_reason.is_some() {
362 let _ = writeln!(out, " @tag :skip");
363 }
364 let _ = writeln!(out, " test \"{escaped_description}\" do");
365 }
366
367 fn render_test_close(&self, out: &mut String) {
369 let _ = writeln!(out, " end");
370 let _ = writeln!(out, " end");
371 }
372
373 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
375 let method = ctx.method.to_lowercase();
376 let mut opts: Vec<String> = Vec::new();
377
378 if let Some(body) = ctx.body {
379 let elixir_val = json_to_elixir(body);
380 opts.push(format!("json: {elixir_val}"));
381 }
382
383 if !ctx.headers.is_empty() {
384 let header_pairs: Vec<String> = ctx
385 .headers
386 .iter()
387 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
388 .collect();
389 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
390 }
391
392 if !ctx.cookies.is_empty() {
393 let cookie_str = ctx
394 .cookies
395 .iter()
396 .map(|(k, v)| format!("{k}={v}"))
397 .collect::<Vec<_>>()
398 .join("; ");
399 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
400 }
401
402 if !ctx.query_params.is_empty() {
403 let pairs: Vec<String> = ctx
404 .query_params
405 .iter()
406 .map(|(k, v)| {
407 let val_str = match v {
408 serde_json::Value::String(s) => s.clone(),
409 other => other.to_string(),
410 };
411 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
412 })
413 .collect();
414 opts.push(format!("params: [{}]", pairs.join(", ")));
415 }
416
417 if (300..400).contains(&self.expected_status) {
420 opts.push("redirect: false".to_string());
421 }
422
423 let fixture_id = escape_elixir(self.fixture_id);
424 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{fixture_id}\"");
425
426 if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
427 if opts.is_empty() {
428 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
429 } else {
430 let opts_str = opts.join(", ");
431 let _ = writeln!(
432 out,
433 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
434 );
435 }
436 } else {
437 opts.insert(0, format!("method: :{method}"));
438 opts.insert(1, format!("url: {url_expr}"));
439 let opts_str = opts.join(", ");
440 let _ = writeln!(out, " {{:ok, response}} = Req.request({opts_str})");
441 }
442 }
443
444 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
445 let _ = writeln!(out, " assert {response_var}.status == {status}");
446 }
447
448 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
453 let header_key = name.to_lowercase();
454 if header_key == "connection" {
456 return;
457 }
458 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
459 let get_header_expr = format!(
460 "Enum.find_value({response_var}.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
461 );
462 match expected {
463 "<<present>>" => {
464 let _ = writeln!(out, " assert {get_header_expr} != nil");
465 }
466 "<<absent>>" => {
467 let _ = writeln!(out, " assert {get_header_expr} == nil");
468 }
469 "<<uuid>>" => {
470 let var = sanitize_ident(&header_key);
471 let _ = writeln!(out, " header_val_{var} = {get_header_expr}");
472 let _ = writeln!(
473 out,
474 " 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}))"
475 );
476 }
477 literal => {
478 let val_lit = format!("\"{}\"", escape_elixir(literal));
479 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
480 }
481 }
482 }
483
484 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
489 let elixir_val = json_to_elixir(expected);
490 match expected {
491 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
492 let _ = writeln!(
493 out,
494 " body_decoded = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
495 );
496 let _ = writeln!(out, " assert body_decoded == {elixir_val}");
497 }
498 _ => {
499 let _ = writeln!(out, " assert {response_var}.body == {elixir_val}");
500 }
501 }
502 }
503
504 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
506 if let Some(obj) = expected.as_object() {
507 let _ = writeln!(
508 out,
509 " decoded_body = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
510 );
511 for (key, val) in obj {
512 let key_lit = format!("\"{}\"", escape_elixir(key));
513 let elixir_val = json_to_elixir(val);
514 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
515 }
516 }
517 }
518
519 fn render_assert_validation_errors(
522 &self,
523 out: &mut String,
524 response_var: &str,
525 errors: &[ValidationErrorExpectation],
526 ) {
527 for err in errors {
528 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
529 let _ = writeln!(
530 out,
531 " assert String.contains?(Jason.encode!({response_var}.body), {msg_lit})"
532 );
533 }
534 }
535}
536
537fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
543 let method = http.request.method.to_uppercase();
544
545 if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
549 let test_name = sanitize_ident(&fixture.id);
550 let description = fixture.description.replace('"', "\\\"");
551 let path = &http.request.path;
552 let _ = writeln!(out, " describe \"{test_name}\" do");
553 let _ = writeln!(out, " @tag :skip");
554 let _ = writeln!(out, " test \"{method} {path} - {description}\" do");
555 let _ = writeln!(out, " end");
556 let _ = writeln!(out, " end");
557 return;
558 }
559
560 let renderer = ElixirTestClientRenderer {
561 fixture_id: &fixture.id,
562 expected_status: http.expected_response.status_code,
563 };
564 client::http_call::render_http_test(out, &renderer, fixture);
565}
566
567#[allow(clippy::too_many_arguments)]
572fn render_test_case(
573 out: &mut String,
574 fixture: &Fixture,
575 e2e_config: &E2eConfig,
576 default_module_path: &str,
577 default_function_name: &str,
578 default_result_var: &str,
579 args: &[crate::config::ArgMapping],
580 field_resolver: &FieldResolver,
581 options_type: Option<&str>,
582 options_default_fn: Option<&str>,
583 enum_fields: &HashMap<String, String>,
584 handle_struct_type: Option<&str>,
585 handle_atom_list_fields: &std::collections::HashSet<String>,
586) {
587 let test_name = sanitize_ident(&fixture.id);
588 let description = fixture.description.replace('"', "\\\"");
589
590 if fixture.mock_response.is_none() {
596 let _ = writeln!(out, " describe \"{test_name}\" do");
597 let _ = writeln!(out, " @tag :skip");
598 let _ = writeln!(out, " test \"{description}\" do");
599 let _ = writeln!(
600 out,
601 " # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
602 );
603 let _ = writeln!(out, " :ok");
604 let _ = writeln!(out, " end");
605 let _ = writeln!(out, " end");
606 return;
607 }
608
609 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
611 let lang = "elixir";
612 let call_overrides = call_config.overrides.get(lang);
613
614 let (module_path, function_name, result_var) = if fixture.call.is_some() {
617 let raw_module = call_overrides
618 .and_then(|o| o.module.as_ref())
619 .cloned()
620 .unwrap_or_else(|| call_config.module.clone());
621 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
622 {
623 raw_module.clone()
624 } else {
625 elixir_module_name(&raw_module)
626 };
627 let base_fn = call_overrides
628 .and_then(|o| o.function.as_ref())
629 .cloned()
630 .unwrap_or_else(|| call_config.function.clone());
631 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
632 format!("{base_fn}_async")
633 } else {
634 base_fn
635 };
636 (resolved_module, resolved_fn, call_config.result_var.clone())
637 } else {
638 (
639 default_module_path.to_string(),
640 default_function_name.to_string(),
641 default_result_var.to_string(),
642 )
643 };
644
645 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
646
647 let (
649 effective_args,
650 effective_options_type,
651 effective_options_default_fn,
652 effective_enum_fields,
653 effective_handle_struct_type,
654 effective_handle_atom_list_fields,
655 );
656 let empty_enum_fields_local: HashMap<String, String>;
657 let empty_atom_fields_local: std::collections::HashSet<String>;
658 let (
659 resolved_args,
660 resolved_options_type,
661 resolved_options_default_fn,
662 resolved_enum_fields_ref,
663 resolved_handle_struct_type,
664 resolved_handle_atom_list_fields_ref,
665 ) = if fixture.call.is_some() {
666 let co = call_config.overrides.get(lang);
667 effective_args = call_config.args.as_slice();
668 effective_options_type = co.and_then(|o| o.options_type.as_deref());
669 effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
670 empty_enum_fields_local = HashMap::new();
671 effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
672 effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
673 empty_atom_fields_local = std::collections::HashSet::new();
674 effective_handle_atom_list_fields = co
675 .map(|o| &o.handle_atom_list_fields)
676 .unwrap_or(&empty_atom_fields_local);
677 (
678 effective_args,
679 effective_options_type,
680 effective_options_default_fn,
681 effective_enum_fields,
682 effective_handle_struct_type,
683 effective_handle_atom_list_fields,
684 )
685 } else {
686 (
687 args as &[_],
688 options_type,
689 options_default_fn,
690 enum_fields,
691 handle_struct_type,
692 handle_atom_list_fields,
693 )
694 };
695
696 let (mut setup_lines, args_str) = build_args_and_setup(
697 &fixture.input,
698 resolved_args,
699 &module_path,
700 resolved_options_type,
701 resolved_options_default_fn,
702 resolved_enum_fields_ref,
703 &fixture.id,
704 resolved_handle_struct_type,
705 resolved_handle_atom_list_fields_ref,
706 );
707
708 let final_args = if let Some(visitor_spec) = &fixture.visitor {
711 let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
712 format!("{args_str}, {visitor_var}")
713 } else {
714 args_str
715 };
716
717 let _ = writeln!(out, " describe \"{test_name}\" do");
718 let _ = writeln!(out, " test \"{description}\" do");
719
720 for line in &setup_lines {
721 let _ = writeln!(out, " {line}");
722 }
723
724 let returns_result = call_config.returns_result;
725
726 if expects_error {
727 if returns_result {
728 let _ = writeln!(
729 out,
730 " assert {{:error, _}} = {module_path}.{function_name}({final_args})"
731 );
732 } else {
733 let _ = writeln!(out, " _result = {module_path}.{function_name}({final_args})");
735 }
736 let _ = writeln!(out, " end");
737 let _ = writeln!(out, " end");
738 return;
739 }
740
741 if returns_result {
742 let _ = writeln!(
743 out,
744 " {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
745 );
746 } else {
747 let _ = writeln!(out, " {result_var} = {module_path}.{function_name}({final_args})");
749 }
750
751 for assertion in &fixture.assertions {
752 render_assertion(out, assertion, &result_var, field_resolver, &module_path);
753 }
754
755 let _ = writeln!(out, " end");
756 let _ = writeln!(out, " end");
757}
758
759#[allow(clippy::too_many_arguments)]
763fn build_args_and_setup(
764 input: &serde_json::Value,
765 args: &[crate::config::ArgMapping],
766 module_path: &str,
767 options_type: Option<&str>,
768 options_default_fn: Option<&str>,
769 enum_fields: &HashMap<String, String>,
770 fixture_id: &str,
771 _handle_struct_type: Option<&str>,
772 _handle_atom_list_fields: &std::collections::HashSet<String>,
773) -> (Vec<String>, String) {
774 if args.is_empty() {
775 return (Vec::new(), json_to_elixir(input));
776 }
777
778 let mut setup_lines: Vec<String> = Vec::new();
779 let mut parts: Vec<String> = Vec::new();
780
781 for arg in args {
782 if arg.arg_type == "mock_url" {
783 setup_lines.push(format!(
784 "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
785 arg.name,
786 ));
787 parts.push(arg.name.clone());
788 continue;
789 }
790
791 if arg.arg_type == "handle" {
792 let constructor_name = format!("create_{}", arg.name.to_snake_case());
796 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
797 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
798 let name = &arg.name;
799 if config_value.is_null()
800 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
801 {
802 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
803 } else {
804 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
807 let escaped = escape_elixir(&json_str);
808 setup_lines.push(format!("{name}_config = \"{escaped}\""));
809 setup_lines.push(format!(
810 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
811 ));
812 }
813 parts.push(arg.name.clone());
814 continue;
815 }
816
817 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
818 let val = input.get(field);
819 match val {
820 None | Some(serde_json::Value::Null) if arg.optional => {
821 parts.push("nil".to_string());
824 continue;
825 }
826 None | Some(serde_json::Value::Null) => {
827 let default_val = match arg.arg_type.as_str() {
829 "string" => "\"\"".to_string(),
830 "int" | "integer" => "0".to_string(),
831 "float" | "number" => "0.0".to_string(),
832 "bool" | "boolean" => "false".to_string(),
833 _ => "nil".to_string(),
834 };
835 parts.push(default_val);
836 }
837 Some(v) => {
838 if arg.arg_type == "file_path" {
841 if let Some(path_str) = v.as_str() {
842 let full_path = format!("../../test_documents/{path_str}");
843 parts.push(format!("\"{}\"", escape_elixir(&full_path)));
844 continue;
845 }
846 }
847 if arg.arg_type == "bytes" {
850 if let Some(raw) = v.as_str() {
851 let var_name = &arg.name;
852 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
853 parts.push(format!("\"{}\"", escape_elixir(raw)));
855 } else {
856 let first = raw.chars().next().unwrap_or('\0');
857 let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
858 && raw
859 .find('/')
860 .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
861 if is_file_path {
862 let full_path = format!("../../test_documents/{raw}");
864 let escaped = escape_elixir(&full_path);
865 setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
866 parts.push(var_name.to_string());
867 } else {
868 setup_lines.push(format!(
870 "{var_name} = Base.decode64!(\"{}\", padding: false)",
871 escape_elixir(raw)
872 ));
873 parts.push(var_name.to_string());
874 }
875 }
876 continue;
877 }
878 }
879 if arg.arg_type == "json_object" && !v.is_null() {
881 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
882 (options_type, options_default_fn, v.as_object())
883 {
884 let options_var = "options";
886 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
887
888 for (k, vv) in obj.iter() {
890 let snake_key = k.to_snake_case();
891 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
892 if let Some(s) = vv.as_str() {
893 let snake_val = s.to_snake_case();
894 format!(":{snake_val}")
896 } else {
897 json_to_elixir(vv)
898 }
899 } else {
900 json_to_elixir(vv)
901 };
902 setup_lines.push(format!(
903 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
904 ));
905 }
906
907 parts.push(options_var.to_string());
909 continue;
910 }
911 if !v.is_null() {
915 let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
916 let escaped = escape_elixir(&json_str);
917 parts.push(format!("\"{escaped}\""));
918 continue;
919 }
920 }
921 parts.push(json_to_elixir(v));
922 }
923 }
924 }
925
926 (setup_lines, parts.join(", "))
927}
928
929fn is_numeric_expr(field_expr: &str) -> bool {
932 field_expr.starts_with("length(")
933}
934
935fn render_assertion(
936 out: &mut String,
937 assertion: &Assertion,
938 result_var: &str,
939 field_resolver: &FieldResolver,
940 module_path: &str,
941) {
942 if let Some(f) = &assertion.field {
945 match f.as_str() {
946 "chunks_have_content" => {
947 let pred =
948 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
949 match assertion.assertion_type.as_str() {
950 "is_true" => {
951 let _ = writeln!(out, " assert {pred}");
952 }
953 "is_false" => {
954 let _ = writeln!(out, " refute {pred}");
955 }
956 _ => {
957 let _ = writeln!(
958 out,
959 " # skipped: unsupported assertion type on synthetic field '{f}'"
960 );
961 }
962 }
963 return;
964 }
965 "chunks_have_embeddings" => {
966 let pred = format!(
967 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
968 );
969 match assertion.assertion_type.as_str() {
970 "is_true" => {
971 let _ = writeln!(out, " assert {pred}");
972 }
973 "is_false" => {
974 let _ = writeln!(out, " refute {pred}");
975 }
976 _ => {
977 let _ = writeln!(
978 out,
979 " # skipped: unsupported assertion type on synthetic field '{f}'"
980 );
981 }
982 }
983 return;
984 }
985 "embeddings" => {
989 match assertion.assertion_type.as_str() {
990 "count_equals" => {
991 if let Some(val) = &assertion.value {
992 let ex_val = json_to_elixir(val);
993 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
994 }
995 }
996 "count_min" => {
997 if let Some(val) = &assertion.value {
998 let ex_val = json_to_elixir(val);
999 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
1000 }
1001 }
1002 "not_empty" => {
1003 let _ = writeln!(out, " assert {result_var} != []");
1004 }
1005 "is_empty" => {
1006 let _ = writeln!(out, " assert {result_var} == []");
1007 }
1008 _ => {
1009 let _ = writeln!(
1010 out,
1011 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1012 );
1013 }
1014 }
1015 return;
1016 }
1017 "embedding_dimensions" => {
1018 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1019 match assertion.assertion_type.as_str() {
1020 "equals" => {
1021 if let Some(val) = &assertion.value {
1022 let ex_val = json_to_elixir(val);
1023 let _ = writeln!(out, " assert {expr} == {ex_val}");
1024 }
1025 }
1026 "greater_than" => {
1027 if let Some(val) = &assertion.value {
1028 let ex_val = json_to_elixir(val);
1029 let _ = writeln!(out, " assert {expr} > {ex_val}");
1030 }
1031 }
1032 _ => {
1033 let _ = writeln!(
1034 out,
1035 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1036 );
1037 }
1038 }
1039 return;
1040 }
1041 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1042 let pred = match f.as_str() {
1043 "embeddings_valid" => {
1044 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1045 }
1046 "embeddings_finite" => {
1047 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1048 }
1049 "embeddings_non_zero" => {
1050 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1051 }
1052 "embeddings_normalized" => {
1053 format!(
1054 "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)"
1055 )
1056 }
1057 _ => unreachable!(),
1058 };
1059 match assertion.assertion_type.as_str() {
1060 "is_true" => {
1061 let _ = writeln!(out, " assert {pred}");
1062 }
1063 "is_false" => {
1064 let _ = writeln!(out, " refute {pred}");
1065 }
1066 _ => {
1067 let _ = writeln!(
1068 out,
1069 " # skipped: unsupported assertion type on synthetic field '{f}'"
1070 );
1071 }
1072 }
1073 return;
1074 }
1075 "keywords" | "keywords_count" => {
1078 let _ = writeln!(
1079 out,
1080 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1081 );
1082 return;
1083 }
1084 _ => {}
1085 }
1086 }
1087
1088 if let Some(f) = &assertion.field {
1090 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1091 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1092 return;
1093 }
1094 }
1095
1096 let field_expr = match &assertion.field {
1097 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1098 _ => result_var.to_string(),
1099 };
1100
1101 let is_numeric = is_numeric_expr(&field_expr);
1104 let trimmed_field_expr = if is_numeric {
1105 field_expr.clone()
1106 } else {
1107 format!("String.trim({field_expr})")
1108 };
1109
1110 match assertion.assertion_type.as_str() {
1111 "equals" => {
1112 if let Some(expected) = &assertion.value {
1113 let elixir_val = json_to_elixir(expected);
1114 let is_string_expected = expected.is_string();
1116 if is_string_expected && !is_numeric {
1117 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1118 } else {
1119 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1120 }
1121 }
1122 }
1123 "contains" => {
1124 if let Some(expected) = &assertion.value {
1125 let elixir_val = json_to_elixir(expected);
1126 let _ = writeln!(
1128 out,
1129 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1130 );
1131 }
1132 }
1133 "contains_all" => {
1134 if let Some(values) = &assertion.values {
1135 for val in values {
1136 let elixir_val = json_to_elixir(val);
1137 let _ = writeln!(
1138 out,
1139 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1140 );
1141 }
1142 }
1143 }
1144 "not_contains" => {
1145 if let Some(expected) = &assertion.value {
1146 let elixir_val = json_to_elixir(expected);
1147 let _ = writeln!(
1148 out,
1149 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1150 );
1151 }
1152 }
1153 "not_empty" => {
1154 let _ = writeln!(out, " assert {field_expr} != \"\"");
1155 }
1156 "is_empty" => {
1157 if is_numeric {
1158 let _ = writeln!(out, " assert {field_expr} == 0");
1160 } else {
1161 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1163 }
1164 }
1165 "contains_any" => {
1166 if let Some(values) = &assertion.values {
1167 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1168 let list_str = items.join(", ");
1169 let _ = writeln!(
1170 out,
1171 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1172 );
1173 }
1174 }
1175 "greater_than" => {
1176 if let Some(val) = &assertion.value {
1177 let elixir_val = json_to_elixir(val);
1178 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
1179 }
1180 }
1181 "less_than" => {
1182 if let Some(val) = &assertion.value {
1183 let elixir_val = json_to_elixir(val);
1184 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
1185 }
1186 }
1187 "greater_than_or_equal" => {
1188 if let Some(val) = &assertion.value {
1189 let elixir_val = json_to_elixir(val);
1190 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
1191 }
1192 }
1193 "less_than_or_equal" => {
1194 if let Some(val) = &assertion.value {
1195 let elixir_val = json_to_elixir(val);
1196 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
1197 }
1198 }
1199 "starts_with" => {
1200 if let Some(expected) = &assertion.value {
1201 let elixir_val = json_to_elixir(expected);
1202 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
1203 }
1204 }
1205 "ends_with" => {
1206 if let Some(expected) = &assertion.value {
1207 let elixir_val = json_to_elixir(expected);
1208 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
1209 }
1210 }
1211 "min_length" => {
1212 if let Some(val) = &assertion.value {
1213 if let Some(n) = val.as_u64() {
1214 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1215 }
1216 }
1217 }
1218 "max_length" => {
1219 if let Some(val) = &assertion.value {
1220 if let Some(n) = val.as_u64() {
1221 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
1222 }
1223 }
1224 }
1225 "count_min" => {
1226 if let Some(val) = &assertion.value {
1227 if let Some(n) = val.as_u64() {
1228 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
1229 }
1230 }
1231 }
1232 "count_equals" => {
1233 if let Some(val) = &assertion.value {
1234 if let Some(n) = val.as_u64() {
1235 let _ = writeln!(out, " assert length({field_expr}) == {n}");
1236 }
1237 }
1238 }
1239 "is_true" => {
1240 let _ = writeln!(out, " assert {field_expr} == true");
1241 }
1242 "is_false" => {
1243 let _ = writeln!(out, " assert {field_expr} == false");
1244 }
1245 "method_result" => {
1246 if let Some(method_name) = &assertion.method {
1247 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1248 let check = assertion.check.as_deref().unwrap_or("is_true");
1249 match check {
1250 "equals" => {
1251 if let Some(val) = &assertion.value {
1252 let elixir_val = json_to_elixir(val);
1253 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
1254 }
1255 }
1256 "is_true" => {
1257 let _ = writeln!(out, " assert {call_expr} == true");
1258 }
1259 "is_false" => {
1260 let _ = writeln!(out, " assert {call_expr} == false");
1261 }
1262 "greater_than_or_equal" => {
1263 if let Some(val) = &assertion.value {
1264 let n = val.as_u64().unwrap_or(0);
1265 let _ = writeln!(out, " assert {call_expr} >= {n}");
1266 }
1267 }
1268 "count_min" => {
1269 if let Some(val) = &assertion.value {
1270 let n = val.as_u64().unwrap_or(0);
1271 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
1272 }
1273 }
1274 "contains" => {
1275 if let Some(val) = &assertion.value {
1276 let elixir_val = json_to_elixir(val);
1277 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
1278 }
1279 }
1280 "is_error" => {
1281 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
1282 }
1283 other_check => {
1284 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1285 }
1286 }
1287 } else {
1288 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1289 }
1290 }
1291 "matches_regex" => {
1292 if let Some(expected) = &assertion.value {
1293 let elixir_val = json_to_elixir(expected);
1294 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1295 }
1296 }
1297 "not_error" => {
1298 }
1300 "error" => {
1301 }
1303 other => {
1304 panic!("Elixir e2e generator: unsupported assertion type: {other}");
1305 }
1306 }
1307}
1308
1309fn build_elixir_method_call(
1312 result_var: &str,
1313 method_name: &str,
1314 args: Option<&serde_json::Value>,
1315 module_path: &str,
1316) -> String {
1317 match method_name {
1318 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1319 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1320 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1321 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1322 "contains_node_type" => {
1323 let node_type = args
1324 .and_then(|a| a.get("node_type"))
1325 .and_then(|v| v.as_str())
1326 .unwrap_or("");
1327 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1328 }
1329 "find_nodes_by_type" => {
1330 let node_type = args
1331 .and_then(|a| a.get("node_type"))
1332 .and_then(|v| v.as_str())
1333 .unwrap_or("");
1334 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1335 }
1336 "run_query" => {
1337 let query_source = args
1338 .and_then(|a| a.get("query_source"))
1339 .and_then(|v| v.as_str())
1340 .unwrap_or("");
1341 let language = args
1342 .and_then(|a| a.get("language"))
1343 .and_then(|v| v.as_str())
1344 .unwrap_or("");
1345 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1346 }
1347 _ => format!("{module_path}.{method_name}({result_var})"),
1348 }
1349}
1350
1351fn elixir_module_name(category: &str) -> String {
1353 use heck::ToUpperCamelCase;
1354 category.to_upper_camel_case()
1355}
1356
1357fn json_to_elixir(value: &serde_json::Value) -> String {
1359 match value {
1360 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1361 serde_json::Value::Bool(true) => "true".to_string(),
1362 serde_json::Value::Bool(false) => "false".to_string(),
1363 serde_json::Value::Number(n) => {
1364 let s = n.to_string().replace("e+", "e");
1368 if s.contains('e') && !s.contains('.') {
1369 s.replacen('e', ".0e", 1)
1371 } else {
1372 s
1373 }
1374 }
1375 serde_json::Value::Null => "nil".to_string(),
1376 serde_json::Value::Array(arr) => {
1377 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1378 format!("[{}]", items.join(", "))
1379 }
1380 serde_json::Value::Object(map) => {
1381 let entries: Vec<String> = map
1382 .iter()
1383 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1384 .collect();
1385 format!("%{{{}}}", entries.join(", "))
1386 }
1387 }
1388}
1389
1390fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1392 use std::fmt::Write as FmtWrite;
1393 let mut visitor_obj = String::new();
1394 let _ = writeln!(visitor_obj, "%{{");
1395 for (method_name, action) in &visitor_spec.callbacks {
1396 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1397 }
1398 let _ = writeln!(visitor_obj, " }}");
1399
1400 setup_lines.push(format!("visitor = {visitor_obj}"));
1401 "visitor".to_string()
1402}
1403
1404fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1406 use std::fmt::Write as FmtWrite;
1407
1408 let handle_method = format!("handle_{}", &method_name[6..]); let params = match method_name {
1411 "visit_link" => "_ctx, _href, _text, _title",
1412 "visit_image" => "_ctx, _src, _alt, _title",
1413 "visit_heading" => "_ctx, _level, text, _id",
1414 "visit_code_block" => "_ctx, _lang, _code",
1415 "visit_code_inline"
1416 | "visit_strong"
1417 | "visit_emphasis"
1418 | "visit_strikethrough"
1419 | "visit_underline"
1420 | "visit_subscript"
1421 | "visit_superscript"
1422 | "visit_mark"
1423 | "visit_button"
1424 | "visit_summary"
1425 | "visit_figcaption"
1426 | "visit_definition_term"
1427 | "visit_definition_description" => "_ctx, _text",
1428 "visit_text" => "_ctx, _text",
1429 "visit_list_item" => "_ctx, _ordered, _marker, _text",
1430 "visit_blockquote" => "_ctx, _content, _depth",
1431 "visit_table_row" => "_ctx, _cells, _is_header",
1432 "visit_custom_element" => "_ctx, _tag_name, _html",
1433 "visit_form" => "_ctx, _action_url, _method",
1434 "visit_input" => "_ctx, _input_type, _name, _value",
1435 "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
1436 "visit_details" => "_ctx, _is_open",
1437 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "_ctx, _output",
1438 "visit_list_start" => "_ctx, _ordered",
1439 "visit_list_end" => "_ctx, _ordered, _output",
1440 _ => "_ctx",
1441 };
1442
1443 let _ = writeln!(out, " :{handle_method} => fn({params}) ->");
1444 match action {
1445 CallbackAction::Skip => {
1446 let _ = writeln!(out, " :skip");
1447 }
1448 CallbackAction::Continue => {
1449 let _ = writeln!(out, " :continue");
1450 }
1451 CallbackAction::PreserveHtml => {
1452 let _ = writeln!(out, " :preserve_html");
1453 }
1454 CallbackAction::Custom { output } => {
1455 let escaped = escape_elixir(output);
1456 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1457 }
1458 CallbackAction::CustomTemplate { template } => {
1459 let escaped = escape_elixir(template);
1461 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1462 }
1463 }
1464 let _ = writeln!(out, " end,");
1465}