Skip to main content

alef_e2e/codegen/
elixir.rs

1//! Elixir e2e test generator using ExUnit.
2
3use 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
20/// Elixir e2e code generator.
21pub 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        // Resolve call config with overrides.
36        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        // Convert module path to Elixir PascalCase if it looks like snake_case
43        // (e.g., "html_to_markdown" -> "HtmlToMarkdown").
44        // If the override already contains "." (e.g., "Elixir.HtmlToMarkdown"), use as-is.
45        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        // Elixir facade exports async variants with `_async` suffix when the call is async.
55        // Append the suffix only if not already present.
56        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        // Check if any fixture in any group is an HTTP test.
73        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        // Check if any fixture needs the mock server (either via http or mock_response or client_factory).
76        let has_mock_server_tests = groups.iter().any(|g| {
77            g.fixtures.iter().any(|f| {
78                if f.needs_mock_server() {
79                    return true;
80                }
81                let cc = e2e_config.resolve_call(f.call.as_deref());
82                let elixir_override = cc
83                    .overrides
84                    .get("elixir")
85                    .or_else(|| e2e_config.call.overrides.get("elixir"));
86                elixir_override.and_then(|o| o.client_factory.as_deref()).is_some()
87            })
88        });
89
90        // Resolve package reference (path or version) for the NIF dependency.
91        let pkg_ref = e2e_config.resolve_package(lang);
92        let pkg_path = if has_nif_tests {
93            pkg_ref.as_ref().and_then(|p| p.path.as_deref()).unwrap_or("")
94        } else {
95            ""
96        };
97
98        // Generate mix.exs. The dep atom must match the binding package's
99        // mix `app:` value, not the crate name. Use the configured
100        // `[elixir].app_name` (the same source the package's own mix.exs
101        // uses); fall back to the crate name only when unset. Without this,
102        // mix's path-dep resolution silently misroutes — the path-dep's
103        // own deps (notably `:rustler_precompiled`) never load during its
104        // compilation and the parent build fails with `RustlerPrecompiled
105        // is not loaded`.
106        let pkg_atom = config.elixir_app_name();
107        files.push(GeneratedFile {
108            path: output_base.join("mix.exs"),
109            content: render_mix_exs(&pkg_atom, pkg_path, e2e_config.dep_mode, has_http_tests, has_nif_tests),
110            generated_header: false,
111        });
112
113        // Generate lib/e2e_elixir.ex — required so the mix project compiles.
114        files.push(GeneratedFile {
115            path: output_base.join("lib").join("e2e_elixir.ex"),
116            content: "defmodule E2eElixir do\n  @moduledoc false\nend\n".to_string(),
117            generated_header: false,
118        });
119
120        // Generate test_helper.exs.
121        files.push(GeneratedFile {
122            path: output_base.join("test").join("test_helper.exs"),
123            content: render_test_helper(has_http_tests || has_mock_server_tests),
124            generated_header: false,
125        });
126
127        // Generate test files per category.
128        for group in groups {
129            let active: Vec<&Fixture> = group
130                .fixtures
131                .iter()
132                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
133                .collect();
134
135            if active.is_empty() {
136                continue;
137            }
138
139            let filename = format!("{}_test.exs", sanitize_filename(&group.category));
140            let field_resolver = FieldResolver::new(
141                &e2e_config.fields,
142                &e2e_config.fields_optional,
143                &e2e_config.result_fields,
144                &e2e_config.fields_array,
145                &std::collections::HashSet::new(),
146            );
147            let content = render_test_file(
148                &group.category,
149                &active,
150                e2e_config,
151                &module_path,
152                &function_name,
153                result_var,
154                &e2e_config.call.args,
155                &field_resolver,
156                options_type.as_deref(),
157                options_default_fn.as_deref(),
158                enum_fields,
159                handle_struct_type.as_deref(),
160                handle_atom_list_fields,
161            );
162            files.push(GeneratedFile {
163                path: output_base.join("test").join(filename),
164                content,
165                generated_header: true,
166            });
167        }
168
169        Ok(files)
170    }
171
172    fn language_name(&self) -> &'static str {
173        "elixir"
174    }
175}
176
177fn render_test_helper(has_http_tests: bool) -> String {
178    if has_http_tests {
179        r#"ExUnit.start()
180
181# Spawn mock-server binary and set MOCK_SERVER_URL for all tests.
182mock_server_bin = Path.expand("../../rust/target/release/mock-server", __DIR__)
183fixtures_dir = Path.expand("../../../fixtures", __DIR__)
184
185if File.exists?(mock_server_bin) do
186  port = Port.open({:spawn_executable, mock_server_bin}, [
187    :binary,
188    :line,
189    args: [fixtures_dir]
190  ])
191  receive do
192    {^port, {:data, {:eol, "MOCK_SERVER_URL=" <> url}}} ->
193      System.put_env("MOCK_SERVER_URL", url)
194  after
195    30_000 ->
196      raise "mock-server startup timeout"
197  end
198end
199"#
200        .to_string()
201    } else {
202        "ExUnit.start()\n".to_string()
203    }
204}
205
206fn render_mix_exs(
207    pkg_name: &str,
208    pkg_path: &str,
209    dep_mode: crate::config::DependencyMode,
210    has_http_tests: bool,
211    has_nif_tests: bool,
212) -> String {
213    let mut out = String::new();
214    let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
215    let _ = writeln!(out, "  use Mix.Project");
216    let _ = writeln!(out);
217    let _ = writeln!(out, "  def project do");
218    let _ = writeln!(out, "    [");
219    let _ = writeln!(out, "      app: :e2e_elixir,");
220    let _ = writeln!(out, "      version: \"0.1.0\",");
221    let _ = writeln!(out, "      elixir: \"~> 1.14\",");
222    let _ = writeln!(out, "      deps: deps()");
223    let _ = writeln!(out, "    ]");
224    let _ = writeln!(out, "  end");
225    let _ = writeln!(out);
226    let _ = writeln!(out, "  defp deps do");
227    let _ = writeln!(out, "    [");
228
229    // Build the list of deps, then join with commas to avoid double-commas.
230    let mut deps: Vec<String> = Vec::new();
231
232    // Add the binding NIF dependency when there are non-HTTP tests.
233    if has_nif_tests && !pkg_path.is_empty() {
234        let pkg_atom = pkg_name;
235        let nif_dep = match dep_mode {
236            crate::config::DependencyMode::Local => {
237                format!("      {{:{pkg_atom}, path: \"{pkg_path}\"}}")
238            }
239            crate::config::DependencyMode::Registry => {
240                // Registry mode: pkg_path is repurposed as the version string.
241                format!("      {{:{pkg_atom}, \"{pkg_path}\"}}")
242            }
243        };
244        deps.push(nif_dep);
245        // rustler_precompiled provides the precompiled NIF loader.
246        deps.push(format!(
247            "      {{:rustler_precompiled, \"{rp}\"}}",
248            rp = tv::hex::RUSTLER_PRECOMPILED
249        ));
250        // rustler must be a direct, non-optional dep in the consumer project for
251        // `force_build: Mix.env() in [:test, :dev]` to actually fetch the rustler hex
252        // package. With `optional: true` mix omits it when no other dep declares it as
253        // required, breaking the build-from-source path used by the e2e suite.
254        deps.push(format!(
255            "      {{:rustler, \"{rustler}\", runtime: false}}",
256            rustler = tv::hex::RUSTLER
257        ));
258    }
259
260    // Add Req + Jason for HTTP testing.
261    if has_http_tests {
262        deps.push(format!("      {{:req, \"{req}\"}}", req = tv::hex::REQ));
263        deps.push(format!("      {{:jason, \"{jason}\"}}", jason = tv::hex::JASON));
264    }
265
266    let _ = writeln!(out, "{}", deps.join(",\n"));
267    let _ = writeln!(out, "    ]");
268    let _ = writeln!(out, "  end");
269    let _ = writeln!(out, "end");
270    out
271}
272
273#[allow(clippy::too_many_arguments)]
274fn render_test_file(
275    category: &str,
276    fixtures: &[&Fixture],
277    e2e_config: &E2eConfig,
278    module_path: &str,
279    function_name: &str,
280    result_var: &str,
281    args: &[crate::config::ArgMapping],
282    field_resolver: &FieldResolver,
283    options_type: Option<&str>,
284    options_default_fn: Option<&str>,
285    enum_fields: &HashMap<String, String>,
286    handle_struct_type: Option<&str>,
287    handle_atom_list_fields: &std::collections::HashSet<String>,
288) -> String {
289    let mut out = String::new();
290    out.push_str(&hash::header(CommentStyle::Hash));
291    let _ = writeln!(out, "# E2e tests for category: {category}");
292    let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
293
294    // Add client helper when there are HTTP fixtures in this group.
295    let has_http = fixtures.iter().any(|f| f.is_http_test());
296
297    // Use async: false for NIF tests — concurrent Tokio runtimes created by DirtyCpu NIFs
298    // on ARM64 macOS cause SIGBUS when tests run in parallel. HTTP-only tests can stay async.
299    let async_flag = if has_http { "true" } else { "false" };
300    let _ = writeln!(out, "  use ExUnit.Case, async: {async_flag}");
301
302    if has_http {
303        let _ = writeln!(out);
304        let _ = writeln!(out, "  defp mock_server_url do");
305        let _ = writeln!(
306            out,
307            "    System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
308        );
309        let _ = writeln!(out, "  end");
310    }
311
312    // Emit a shared helper for array field contains assertions — extracts string
313    // representations from each item's attributes so String.contains? works on struct lists.
314    let has_array_contains = fixtures.iter().any(|fixture| {
315        fixture.assertions.iter().any(|a| {
316            matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
317                && a.field
318                    .as_deref()
319                    .is_some_and(|f| !f.is_empty() && field_resolver.is_array(field_resolver.resolve(f)))
320        })
321    });
322    if has_array_contains {
323        let _ = writeln!(out);
324        let _ = writeln!(out, "  defp alef_e2e_item_texts(item) when is_binary(item), do: [item]");
325        let _ = writeln!(out, "  defp alef_e2e_item_texts(item) do");
326        let _ = writeln!(out, "    [:kind, :name, :signature, :path, :alias, :text, :source]");
327        let _ = writeln!(out, "    |> Enum.filter(&Map.has_key?(item, &1))");
328        let _ = writeln!(out, "    |> Enum.flat_map(fn attr ->");
329        let _ = writeln!(out, "      case Map.get(item, attr) do");
330        let _ = writeln!(out, "        nil -> []");
331        let _ = writeln!(
332            out,
333            "        atom when is_atom(atom) -> [atom |> to_string() |> String.capitalize()]"
334        );
335        let _ = writeln!(out, "        str -> [to_string(str)]");
336        let _ = writeln!(out, "      end");
337        let _ = writeln!(out, "    end)");
338        let _ = writeln!(out, "  end");
339    }
340
341    let _ = writeln!(out);
342
343    for (i, fixture) in fixtures.iter().enumerate() {
344        if let Some(http) = &fixture.http {
345            render_http_test_case(&mut out, fixture, http);
346        } else {
347            render_test_case(
348                &mut out,
349                fixture,
350                e2e_config,
351                module_path,
352                function_name,
353                result_var,
354                args,
355                field_resolver,
356                options_type,
357                options_default_fn,
358                enum_fields,
359                handle_struct_type,
360                handle_atom_list_fields,
361            );
362        }
363        if i + 1 < fixtures.len() {
364            let _ = writeln!(out);
365        }
366    }
367
368    let _ = writeln!(out, "end");
369    out
370}
371
372// ---------------------------------------------------------------------------
373// HTTP test rendering
374// ---------------------------------------------------------------------------
375
376/// HTTP methods that Finch (Req's underlying HTTP client) does not support.
377/// Tests using these methods are emitted with `@tag :skip` so they don't fail.
378const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
379
380/// HTTP methods that Req exposes as convenience functions.
381/// All others must be called via `Req.request(method: :METHOD, ...)`.
382const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
383
384/// Thin renderer that emits ExUnit `describe` + `test` blocks targeting a mock
385/// server via `Req`. Satisfies [`client::TestClientRenderer`] so the shared
386/// [`client::http_call::render_http_test`] driver drives the call sequence.
387struct ElixirTestClientRenderer<'a> {
388    /// The fixture id is needed in [`render_call`] to build the mock server URL
389    /// (`mock_server_url()/fixtures/<id>`).
390    fixture_id: &'a str,
391    /// Expected response status, needed to disable Req's redirect-following for 3xx.
392    expected_status: u16,
393}
394
395impl<'a> client::TestClientRenderer for ElixirTestClientRenderer<'a> {
396    fn language_name(&self) -> &'static str {
397        "elixir"
398    }
399
400    /// Emit `describe "{fn_name}" do` + inner `test "METHOD PATH - description" do`.
401    ///
402    /// When `skip_reason` is `Some`, emit `@tag :skip` before the test block so
403    /// ExUnit skips it; the shared driver short-circuits before emitting any
404    /// assertions and then calls `render_test_close` for symmetry.
405    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
406        let escaped_description = description.replace('"', "\\\"");
407        let _ = writeln!(out, "  describe \"{fn_name}\" do");
408        if skip_reason.is_some() {
409            let _ = writeln!(out, "    @tag :skip");
410        }
411        let _ = writeln!(out, "    test \"{escaped_description}\" do");
412    }
413
414    /// Close the inner `test` block and the outer `describe` block.
415    fn render_test_close(&self, out: &mut String) {
416        let _ = writeln!(out, "    end");
417        let _ = writeln!(out, "  end");
418    }
419
420    /// Emit a `Req` request to the mock server using `mock_server_url()/fixtures/<id>`.
421    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
422        let method = ctx.method.to_lowercase();
423        let mut opts: Vec<String> = Vec::new();
424
425        if let Some(body) = ctx.body {
426            let elixir_val = json_to_elixir(body);
427            opts.push(format!("json: {elixir_val}"));
428        }
429
430        if !ctx.headers.is_empty() {
431            let header_pairs: Vec<String> = ctx
432                .headers
433                .iter()
434                .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
435                .collect();
436            opts.push(format!("headers: [{}]", header_pairs.join(", ")));
437        }
438
439        if !ctx.cookies.is_empty() {
440            let cookie_str = ctx
441                .cookies
442                .iter()
443                .map(|(k, v)| format!("{k}={v}"))
444                .collect::<Vec<_>>()
445                .join("; ");
446            opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
447        }
448
449        if !ctx.query_params.is_empty() {
450            let pairs: Vec<String> = ctx
451                .query_params
452                .iter()
453                .map(|(k, v)| {
454                    let val_str = match v {
455                        serde_json::Value::String(s) => s.clone(),
456                        other => other.to_string(),
457                    };
458                    format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
459                })
460                .collect();
461            opts.push(format!("params: [{}]", pairs.join(", ")));
462        }
463
464        // When the expected response is a redirect (3xx), disable automatic redirect
465        // following so the test can assert the redirect status and Location header.
466        if (300..400).contains(&self.expected_status) {
467            opts.push("redirect: false".to_string());
468        }
469
470        let fixture_id = escape_elixir(self.fixture_id);
471        let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{fixture_id}\"");
472
473        if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
474            if opts.is_empty() {
475                let _ = writeln!(out, "      {{:ok, response}} = Req.{method}(url: {url_expr})");
476            } else {
477                let opts_str = opts.join(", ");
478                let _ = writeln!(
479                    out,
480                    "      {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
481                );
482            }
483        } else {
484            opts.insert(0, format!("method: :{method}"));
485            opts.insert(1, format!("url: {url_expr}"));
486            let opts_str = opts.join(", ");
487            let _ = writeln!(out, "      {{:ok, response}} = Req.request({opts_str})");
488        }
489    }
490
491    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
492        let _ = writeln!(out, "      assert {response_var}.status == {status}");
493    }
494
495    /// Emit a header assertion.
496    ///
497    /// Handles the special tokens `<<present>>`, `<<absent>>`, `<<uuid>>`.
498    /// Skips the `connection` header (hop-by-hop, stripped by Req/Mint).
499    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
500        let header_key = name.to_lowercase();
501        // Req (via Mint) strips hop-by-hop headers; asserting on them is meaningless.
502        if header_key == "connection" {
503            return;
504        }
505        let key_lit = format!("\"{}\"", escape_elixir(&header_key));
506        let get_header_expr = format!(
507            "Enum.find_value({response_var}.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
508        );
509        match expected {
510            "<<present>>" => {
511                let _ = writeln!(out, "      assert {get_header_expr} != nil");
512            }
513            "<<absent>>" => {
514                let _ = writeln!(out, "      assert {get_header_expr} == nil");
515            }
516            "<<uuid>>" => {
517                let var = sanitize_ident(&header_key);
518                let _ = writeln!(out, "      header_val_{var} = {get_header_expr}");
519                let _ = writeln!(
520                    out,
521                    "      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}))"
522                );
523            }
524            literal => {
525                let val_lit = format!("\"{}\"", escape_elixir(literal));
526                let _ = writeln!(out, "      assert {get_header_expr} == {val_lit}");
527            }
528        }
529    }
530
531    /// Emit a full JSON body equality assertion.
532    ///
533    /// Req auto-decodes `application/json` bodies; when the response body is a
534    /// binary (non-JSON content type), decode it with `Jason.decode!` first.
535    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
536        let elixir_val = json_to_elixir(expected);
537        match expected {
538            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
539                let _ = writeln!(
540                    out,
541                    "      body_decoded = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
542                );
543                let _ = writeln!(out, "      assert body_decoded == {elixir_val}");
544            }
545            _ => {
546                let _ = writeln!(out, "      assert {response_var}.body == {elixir_val}");
547            }
548        }
549    }
550
551    /// Emit partial body assertions: one assertion per key in `expected`.
552    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
553        if let Some(obj) = expected.as_object() {
554            let _ = writeln!(
555                out,
556                "      decoded_body = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
557            );
558            for (key, val) in obj {
559                let key_lit = format!("\"{}\"", escape_elixir(key));
560                let elixir_val = json_to_elixir(val);
561                let _ = writeln!(out, "      assert decoded_body[{key_lit}] == {elixir_val}");
562            }
563        }
564    }
565
566    /// Emit validation-error assertions, checking each expected `msg` appears in
567    /// the encoded response body.
568    fn render_assert_validation_errors(
569        &self,
570        out: &mut String,
571        response_var: &str,
572        errors: &[ValidationErrorExpectation],
573    ) {
574        for err in errors {
575            let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
576            let _ = writeln!(
577                out,
578                "      assert String.contains?(Jason.encode!({response_var}.body), {msg_lit})"
579            );
580        }
581    }
582}
583
584/// Render an ExUnit `describe` + `test` block for an HTTP server test fixture.
585///
586/// Delegates to [`client::http_call::render_http_test`] after the one
587/// Elixir-specific pre-condition: HTTP methods unsupported by Finch (the
588/// underlying Req adapter) are emitted with `@tag :skip` directly.
589fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
590    let method = http.request.method.to_uppercase();
591
592    // Finch does not support TRACE/CONNECT — emit a skipped test stub directly
593    // rather than routing through the shared driver, which would assert on the
594    // response and fail.
595    if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
596        let test_name = sanitize_ident(&fixture.id);
597        let test_label = fixture.id.replace('"', "\\\"");
598        let path = &http.request.path;
599        let _ = writeln!(out, "  describe \"{test_name}\" do");
600        let _ = writeln!(out, "    @tag :skip");
601        let _ = writeln!(out, "    test \"{method} {path} - {test_label}\" do");
602        let _ = writeln!(out, "    end");
603        let _ = writeln!(out, "  end");
604        return;
605    }
606
607    let renderer = ElixirTestClientRenderer {
608        fixture_id: &fixture.id,
609        expected_status: http.expected_response.status_code,
610    };
611    client::http_call::render_http_test(out, &renderer, fixture);
612}
613
614// ---------------------------------------------------------------------------
615// Function-call test rendering
616// ---------------------------------------------------------------------------
617
618#[allow(clippy::too_many_arguments)]
619fn render_test_case(
620    out: &mut String,
621    fixture: &Fixture,
622    e2e_config: &E2eConfig,
623    default_module_path: &str,
624    default_function_name: &str,
625    default_result_var: &str,
626    args: &[crate::config::ArgMapping],
627    field_resolver: &FieldResolver,
628    options_type: Option<&str>,
629    options_default_fn: Option<&str>,
630    enum_fields: &HashMap<String, String>,
631    handle_struct_type: Option<&str>,
632    handle_atom_list_fields: &std::collections::HashSet<String>,
633) {
634    let test_name = sanitize_ident(&fixture.id);
635    let test_label = fixture.id.replace('"', "\\\"");
636
637    // Non-HTTP non-mock_response fixtures (e.g. AsyncAPI, WebSocket, OpenRPC
638    // protocol-only fixtures) cannot be tested via the configured `[e2e.call]`
639    // function when the binding does not expose it. Emit a documented `@tag :skip`
640    // test so the suite stays compilable. HTTP fixtures dispatch via render_http_test_case
641    // and never reach here.
642    if fixture.mock_response.is_none() && !fixture_has_elixir_callable(fixture, e2e_config) {
643        let _ = writeln!(out, "  describe \"{test_name}\" do");
644        let _ = writeln!(out, "    @tag :skip");
645        let _ = writeln!(out, "    test \"{test_label}\" do");
646        let _ = writeln!(
647            out,
648            "      # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
649        );
650        let _ = writeln!(out, "      :ok");
651        let _ = writeln!(out, "    end");
652        let _ = writeln!(out, "  end");
653        return;
654    }
655
656    // Resolve per-fixture call config (falls back to default if fixture.call is None).
657    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
658    let lang = "elixir";
659    let call_overrides = call_config.overrides.get(lang);
660
661    // Check if the function is excluded from the Elixir binding (e.g., batch functions
662    // that require unsafe NIF tuple marshalling). Emit a skipped test with rationale.
663    let base_fn = call_overrides
664        .and_then(|o| o.function.as_ref())
665        .cloned()
666        .unwrap_or_else(|| call_config.function.clone());
667    if base_fn.starts_with("batch_extract_") {
668        let _ = writeln!(
669            out,
670            "  describe \"{test_name}\" do",
671            test_name = sanitize_ident(&fixture.id)
672        );
673        let _ = writeln!(out, "    @tag :skip");
674        let _ = writeln!(
675            out,
676            "    test \"{test_label}\" do",
677            test_label = fixture.id.replace('"', "\\\"")
678        );
679        let _ = writeln!(
680            out,
681            "      # batch functions excluded from Elixir binding: unsafe NIF tuple marshalling"
682        );
683        let _ = writeln!(out, "      :ok");
684        let _ = writeln!(out, "    end");
685        let _ = writeln!(out, "  end");
686        return;
687    }
688
689    // Compute module_path and function_name from the resolved call config,
690    // applying Elixir-specific PascalCase conversion.
691    let (module_path, function_name, result_var) = if fixture.call.is_some() {
692        let raw_module = call_overrides
693            .and_then(|o| o.module.as_ref())
694            .cloned()
695            .unwrap_or_else(|| call_config.module.clone());
696        let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
697        {
698            raw_module.clone()
699        } else {
700            elixir_module_name(&raw_module)
701        };
702        let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
703            format!("{base_fn}_async")
704        } else {
705            base_fn
706        };
707        (resolved_module, resolved_fn, call_config.result_var.clone())
708    } else {
709        (
710            default_module_path.to_string(),
711            default_function_name.to_string(),
712            default_result_var.to_string(),
713        )
714    };
715
716    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
717
718    // When the fixture uses a named call, use the args and options from that call's config.
719    let (
720        effective_args,
721        effective_options_type,
722        effective_options_default_fn,
723        effective_enum_fields,
724        effective_handle_struct_type,
725        effective_handle_atom_list_fields,
726    );
727    let empty_enum_fields_local: HashMap<String, String>;
728    let empty_atom_fields_local: std::collections::HashSet<String>;
729    let (
730        resolved_args,
731        resolved_options_type,
732        resolved_options_default_fn,
733        resolved_enum_fields_ref,
734        resolved_handle_struct_type,
735        resolved_handle_atom_list_fields_ref,
736    ) = if fixture.call.is_some() {
737        let co = call_config.overrides.get(lang);
738        effective_args = call_config.args.as_slice();
739        effective_options_type = co.and_then(|o| o.options_type.as_deref());
740        effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
741        empty_enum_fields_local = HashMap::new();
742        effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
743        effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
744        empty_atom_fields_local = std::collections::HashSet::new();
745        effective_handle_atom_list_fields = co
746            .map(|o| &o.handle_atom_list_fields)
747            .unwrap_or(&empty_atom_fields_local);
748        (
749            effective_args,
750            effective_options_type,
751            effective_options_default_fn,
752            effective_enum_fields,
753            effective_handle_struct_type,
754            effective_handle_atom_list_fields,
755        )
756    } else {
757        (
758            args as &[_],
759            options_type,
760            options_default_fn,
761            enum_fields,
762            handle_struct_type,
763            handle_atom_list_fields,
764        )
765    };
766
767    let (mut setup_lines, args_str) = build_args_and_setup(
768        &fixture.input,
769        resolved_args,
770        &module_path,
771        resolved_options_type,
772        resolved_options_default_fn,
773        resolved_enum_fields_ref,
774        &fixture.id,
775        resolved_handle_struct_type,
776        resolved_handle_atom_list_fields_ref,
777    );
778
779    // Build visitor if present — it will be injected into the options map.
780    let visitor_var = fixture
781        .visitor
782        .as_ref()
783        .map(|visitor_spec| build_elixir_visitor(&mut setup_lines, visitor_spec));
784
785    // If we have a visitor and the args contain a nil for options, replace it with a map
786    // containing the visitor. The fixture.visitor is already set above.
787    let final_args = if let Some(ref visitor_var) = visitor_var {
788        // Parse args_str to handle injection properly.
789        // Since we're dealing with a 2-arg function (html, options), and options might be nil,
790        // we need to inject visitor into the options.
791        let parts: Vec<&str> = args_str.split(", ").collect();
792        if parts.len() == 2 && parts[1] == "nil" {
793            // Replace nil with %{visitor: visitor}
794            format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
795        } else if parts.len() == 2 {
796            // Options is a variable (e.g., "options") — add visitor to it
797            setup_lines.push(format!(
798                "{} = Map.put({}, :visitor, {})",
799                parts[1], parts[1], visitor_var
800            ));
801            args_str
802        } else if parts.len() == 1 {
803            // Only HTML provided — create options map with just visitor
804            format!("{}, %{{visitor: {}}}", parts[0], visitor_var)
805        } else {
806            args_str
807        }
808    } else {
809        args_str
810    };
811
812    // Client factory: when configured, create a client and pass it as the first argument.
813    let client_factory = call_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
814        e2e_config
815            .call
816            .overrides
817            .get("elixir")
818            .and_then(|o| o.client_factory.as_deref())
819    });
820
821    // Prefix the client variable to the args when client_factory is set.
822    let effective_args = if client_factory.is_some() {
823        if final_args.is_empty() {
824            "client".to_string()
825        } else {
826            format!("client, {final_args}")
827        }
828    } else {
829        final_args
830    };
831
832    let _ = writeln!(out, "  describe \"{test_name}\" do");
833    let _ = writeln!(out, "    test \"{test_label}\" do");
834
835    for line in &setup_lines {
836        let _ = writeln!(out, "      {line}");
837    }
838
839    // Emit client creation when client_factory is configured.
840    if let Some(factory) = client_factory {
841        let fixture_id = &fixture.id;
842        let _ = writeln!(
843            out,
844            "      {{:ok, client}} = {module_path}.{factory}(\"test-key\", (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\")"
845        );
846    }
847
848    // Use returns_result from the Elixir override if present, otherwise from base config
849    let returns_result = call_overrides
850        .and_then(|o| o.returns_result)
851        .unwrap_or(call_config.returns_result || client_factory.is_some());
852
853    if expects_error {
854        if returns_result {
855            let _ = writeln!(
856                out,
857                "      assert {{:error, _}} = {module_path}.{function_name}({effective_args})"
858            );
859        } else {
860            // Non-Result function — just call and discard; error detection not meaningful.
861            let _ = writeln!(out, "      _result = {module_path}.{function_name}({effective_args})");
862        }
863        let _ = writeln!(out, "    end");
864        let _ = writeln!(out, "  end");
865        return;
866    }
867
868    if returns_result {
869        let _ = writeln!(
870            out,
871            "      {{:ok, {result_var}}} = {module_path}.{function_name}({effective_args})"
872        );
873    } else {
874        // Non-Result function returns value directly (e.g., bool, String).
875        let _ = writeln!(
876            out,
877            "      {result_var} = {module_path}.{function_name}({effective_args})"
878        );
879    }
880
881    for assertion in &fixture.assertions {
882        render_assertion(out, assertion, &result_var, field_resolver, &module_path);
883    }
884
885    let _ = writeln!(out, "    end");
886    let _ = writeln!(out, "  end");
887}
888
889/// Build setup lines (e.g. handle creation) and the argument list for the function call.
890///
891/// Returns `(setup_lines, args_string)`.
892#[allow(clippy::too_many_arguments)]
893/// Emit Elixir batch item map constructors for BatchBytesItem or BatchFileItem arrays.
894fn emit_elixir_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
895    if let Some(items) = arr.as_array() {
896        let item_strs: Vec<String> = items
897            .iter()
898            .filter_map(|item| {
899                if let Some(obj) = item.as_object() {
900                    match elem_type {
901                        "BatchBytesItem" => {
902                            let content = obj.get("content").and_then(|v| v.as_array());
903                            let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
904                            let content_code = if let Some(arr) = content {
905                                let bytes: Vec<String> =
906                                    arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
907                                format!("<<{}>>", bytes.join(", "))
908                            } else {
909                                "<<>>".to_string()
910                            };
911                            Some(format!(
912                                "%BatchBytesItem{{content: {}, mime_type: \"{}\"}}",
913                                content_code, mime_type
914                            ))
915                        }
916                        "BatchFileItem" => {
917                            let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
918                            Some(format!("%BatchFileItem{{path: \"{}\"}}", path))
919                        }
920                        _ => None,
921                    }
922                } else {
923                    None
924                }
925            })
926            .collect();
927        format!("[{}]", item_strs.join(", "))
928    } else {
929        "[]".to_string()
930    }
931}
932
933#[allow(clippy::too_many_arguments)]
934fn build_args_and_setup(
935    input: &serde_json::Value,
936    args: &[crate::config::ArgMapping],
937    module_path: &str,
938    options_type: Option<&str>,
939    options_default_fn: Option<&str>,
940    enum_fields: &HashMap<String, String>,
941    fixture_id: &str,
942    _handle_struct_type: Option<&str>,
943    _handle_atom_list_fields: &std::collections::HashSet<String>,
944) -> (Vec<String>, String) {
945    if args.is_empty() {
946        // No args config: pass the whole input only when it's non-empty.
947        // Functions with no parameters (e.g. language_count) have empty input
948        // and must be called with no arguments — not with `%{}`.
949        let is_empty_input = match input {
950            serde_json::Value::Null => true,
951            serde_json::Value::Object(m) => m.is_empty(),
952            _ => false,
953        };
954        if is_empty_input {
955            return (Vec::new(), String::new());
956        }
957        return (Vec::new(), json_to_elixir(input));
958    }
959
960    let mut setup_lines: Vec<String> = Vec::new();
961    let mut parts: Vec<String> = Vec::new();
962
963    for arg in args {
964        if arg.arg_type == "mock_url" {
965            setup_lines.push(format!(
966                "{} = (System.get_env(\"MOCK_SERVER_URL\") || \"\") <> \"/fixtures/{fixture_id}\"",
967                arg.name,
968            ));
969            parts.push(arg.name.clone());
970            continue;
971        }
972
973        if arg.arg_type == "handle" {
974            // Generate a create_{name} call using {:ok, name} = ... pattern.
975            // The NIF now accepts config as an optional JSON string (not a NifStruct/NifMap)
976            // so that partial maps work: serde_json::from_str respects #[serde(default)].
977            let constructor_name = format!("create_{}", arg.name.to_snake_case());
978            let config_value = if arg.field == "input" {
979                input
980            } else {
981                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
982                input.get(field).unwrap_or(&serde_json::Value::Null)
983            };
984            let name = &arg.name;
985            if config_value.is_null()
986                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
987            {
988                setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
989            } else {
990                // Serialize the config map to a JSON string with Jason so that Rust can
991                // deserialize it with serde_json and apply field defaults for missing keys.
992                let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
993                let escaped = escape_elixir(&json_str);
994                setup_lines.push(format!("{name}_config = \"{escaped}\""));
995                setup_lines.push(format!(
996                    "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
997                ));
998            }
999            parts.push(arg.name.clone());
1000            continue;
1001        }
1002
1003        let val = if arg.field == "input" {
1004            Some(input)
1005        } else {
1006            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1007            input.get(field)
1008        };
1009        match val {
1010            None | Some(serde_json::Value::Null) if arg.optional => {
1011                // Elixir functions have fixed positional arity — pass nil for optional args
1012                // rather than skipping them, so the call site has the correct arity.
1013                parts.push("nil".to_string());
1014                continue;
1015            }
1016            None | Some(serde_json::Value::Null) => {
1017                // Required arg with no fixture value: pass a language-appropriate default.
1018                let default_val = match arg.arg_type.as_str() {
1019                    "string" => "\"\"".to_string(),
1020                    "int" | "integer" => "0".to_string(),
1021                    "float" | "number" => "0.0".to_string(),
1022                    "bool" | "boolean" => "false".to_string(),
1023                    _ => "nil".to_string(),
1024                };
1025                parts.push(default_val);
1026            }
1027            Some(v) => {
1028                // For file_path args, prepend the path to the test_documents directory
1029                // relative to the e2e/elixir/ directory where `mix test` runs.
1030                if arg.arg_type == "file_path" {
1031                    if let Some(path_str) = v.as_str() {
1032                        let full_path = format!("../../test_documents/{path_str}");
1033                        parts.push(format!("\"{}\"", escape_elixir(&full_path)));
1034                        continue;
1035                    }
1036                }
1037                // For bytes args, use File.read! for file paths and Base.decode64! for base64.
1038                // Inline text (starts with '<', '{', '[' or contains spaces) is used as-is (UTF-8 binary).
1039                if arg.arg_type == "bytes" {
1040                    if let Some(raw) = v.as_str() {
1041                        let var_name = &arg.name;
1042                        if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
1043                            // Inline text — use as a binary string.
1044                            parts.push(format!("\"{}\"", escape_elixir(raw)));
1045                        } else {
1046                            let first = raw.chars().next().unwrap_or('\0');
1047                            let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
1048                                && raw
1049                                    .find('/')
1050                                    .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
1051                            if is_file_path {
1052                                // Looks like "dir/file.ext" — read from test_documents.
1053                                let full_path = format!("../../test_documents/{raw}");
1054                                let escaped = escape_elixir(&full_path);
1055                                setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
1056                                parts.push(var_name.to_string());
1057                            } else {
1058                                // Treat as base64-encoded binary.
1059                                setup_lines.push(format!(
1060                                    "{var_name} = Base.decode64!(\"{}\", padding: false)",
1061                                    escape_elixir(raw)
1062                                ));
1063                                parts.push(var_name.to_string());
1064                            }
1065                        }
1066                        continue;
1067                    }
1068                }
1069                // For json_object args with options_type+options_via, build a proper struct.
1070                if arg.arg_type == "json_object" && !v.is_null() {
1071                    if let (Some(_opts_type), Some(options_fn), Some(obj)) =
1072                        (options_type, options_default_fn, v.as_object())
1073                    {
1074                        // Add setup line to initialize options from default function.
1075                        let options_var = "options";
1076                        setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
1077
1078                        // For each field in the options object, add a struct update line.
1079                        for (k, vv) in obj.iter() {
1080                            let snake_key = k.to_snake_case();
1081                            let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
1082                                if let Some(s) = vv.as_str() {
1083                                    let snake_val = s.to_snake_case();
1084                                    // Use atom for enum values, not string
1085                                    format!(":{snake_val}")
1086                                } else {
1087                                    json_to_elixir(vv)
1088                                }
1089                            } else {
1090                                json_to_elixir(vv)
1091                            };
1092                            setup_lines.push(format!(
1093                                "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
1094                            ));
1095                        }
1096
1097                        // Push the variable name as the argument.
1098                        parts.push(options_var.to_string());
1099                        continue;
1100                    }
1101                    // When element_type is set to a batch item type, wrap items with constructors.
1102                    if let Some(elem_type) = &arg.element_type {
1103                        if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1104                            parts.push(emit_elixir_batch_item_array(v, elem_type));
1105                            continue;
1106                        }
1107                        // When element_type is set to a simple type (e.g. Vec<String>).
1108                        // The NIF accepts an Elixir list directly — emit one.
1109                        if v.is_array() {
1110                            parts.push(json_to_elixir(v));
1111                            continue;
1112                        }
1113                    }
1114                    // When there's no options_type+options_via, the Elixir NIF expects a JSON
1115                    // string (Option<String> decoded by serde_json) rather than an Elixir map.
1116                    // Serialize the JSON value to a string literal here.
1117                    if !v.is_null() {
1118                        let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
1119                        let escaped = escape_elixir(&json_str);
1120                        parts.push(format!("\"{escaped}\""));
1121                        continue;
1122                    }
1123                }
1124                parts.push(json_to_elixir(v));
1125            }
1126        }
1127    }
1128
1129    (setup_lines, parts.join(", "))
1130}
1131
1132/// Returns true if the field expression is a numeric/integer expression
1133/// (e.g., a `length(...)` call) rather than a string.
1134fn is_numeric_expr(field_expr: &str) -> bool {
1135    field_expr.starts_with("length(")
1136}
1137
1138fn render_assertion(
1139    out: &mut String,
1140    assertion: &Assertion,
1141    result_var: &str,
1142    field_resolver: &FieldResolver,
1143    module_path: &str,
1144) {
1145    // Handle synthetic / derived fields before the is_valid_for_result check
1146    // so they are never treated as struct field accesses on the result.
1147    if let Some(f) = &assertion.field {
1148        match f.as_str() {
1149            "chunks_have_content" => {
1150                let pred =
1151                    format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
1152                match assertion.assertion_type.as_str() {
1153                    "is_true" => {
1154                        let _ = writeln!(out, "      assert {pred}");
1155                    }
1156                    "is_false" => {
1157                        let _ = writeln!(out, "      refute {pred}");
1158                    }
1159                    _ => {
1160                        let _ = writeln!(
1161                            out,
1162                            "      # skipped: unsupported assertion type on synthetic field '{f}'"
1163                        );
1164                    }
1165                }
1166                return;
1167            }
1168            "chunks_have_embeddings" => {
1169                let pred = format!(
1170                    "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
1171                );
1172                match assertion.assertion_type.as_str() {
1173                    "is_true" => {
1174                        let _ = writeln!(out, "      assert {pred}");
1175                    }
1176                    "is_false" => {
1177                        let _ = writeln!(out, "      refute {pred}");
1178                    }
1179                    _ => {
1180                        let _ = writeln!(
1181                            out,
1182                            "      # skipped: unsupported assertion type on synthetic field '{f}'"
1183                        );
1184                    }
1185                }
1186                return;
1187            }
1188            // ---- EmbedResponse virtual fields ----
1189            // embed_texts returns [[float]] in Elixir — no wrapper struct.
1190            // result_var is the embedding matrix; use it directly.
1191            "embeddings" => {
1192                match assertion.assertion_type.as_str() {
1193                    "count_equals" => {
1194                        if let Some(val) = &assertion.value {
1195                            let ex_val = json_to_elixir(val);
1196                            let _ = writeln!(out, "      assert length({result_var}) == {ex_val}");
1197                        }
1198                    }
1199                    "count_min" => {
1200                        if let Some(val) = &assertion.value {
1201                            let ex_val = json_to_elixir(val);
1202                            let _ = writeln!(out, "      assert length({result_var}) >= {ex_val}");
1203                        }
1204                    }
1205                    "not_empty" => {
1206                        let _ = writeln!(out, "      assert {result_var} != []");
1207                    }
1208                    "is_empty" => {
1209                        let _ = writeln!(out, "      assert {result_var} == []");
1210                    }
1211                    _ => {
1212                        let _ = writeln!(
1213                            out,
1214                            "      # skipped: unsupported assertion type on synthetic field 'embeddings'"
1215                        );
1216                    }
1217                }
1218                return;
1219            }
1220            "embedding_dimensions" => {
1221                let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1222                match assertion.assertion_type.as_str() {
1223                    "equals" => {
1224                        if let Some(val) = &assertion.value {
1225                            let ex_val = json_to_elixir(val);
1226                            let _ = writeln!(out, "      assert {expr} == {ex_val}");
1227                        }
1228                    }
1229                    "greater_than" => {
1230                        if let Some(val) = &assertion.value {
1231                            let ex_val = json_to_elixir(val);
1232                            let _ = writeln!(out, "      assert {expr} > {ex_val}");
1233                        }
1234                    }
1235                    _ => {
1236                        let _ = writeln!(
1237                            out,
1238                            "      # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1239                        );
1240                    }
1241                }
1242                return;
1243            }
1244            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1245                let pred = match f.as_str() {
1246                    "embeddings_valid" => {
1247                        format!("Enum.all?({result_var}, fn e -> e != [] end)")
1248                    }
1249                    "embeddings_finite" => {
1250                        format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1251                    }
1252                    "embeddings_non_zero" => {
1253                        format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1254                    }
1255                    "embeddings_normalized" => {
1256                        format!(
1257                            "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)"
1258                        )
1259                    }
1260                    _ => unreachable!(),
1261                };
1262                match assertion.assertion_type.as_str() {
1263                    "is_true" => {
1264                        let _ = writeln!(out, "      assert {pred}");
1265                    }
1266                    "is_false" => {
1267                        let _ = writeln!(out, "      refute {pred}");
1268                    }
1269                    _ => {
1270                        let _ = writeln!(
1271                            out,
1272                            "      # skipped: unsupported assertion type on synthetic field '{f}'"
1273                        );
1274                    }
1275                }
1276                return;
1277            }
1278            // ---- keywords / keywords_count ----
1279            // Elixir ExtractionResult does not expose extracted_keywords; skip.
1280            "keywords" | "keywords_count" => {
1281                let _ = writeln!(
1282                    out,
1283                    "      # skipped: field '{f}' not available on Elixir ExtractionResult"
1284                );
1285                return;
1286            }
1287            _ => {}
1288        }
1289    }
1290
1291    // Skip assertions on fields that don't exist on the result type.
1292    if let Some(f) = &assertion.field {
1293        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1294            let _ = writeln!(out, "      # skipped: field '{f}' not available on result type");
1295            return;
1296        }
1297    }
1298
1299    let field_expr = match &assertion.field {
1300        Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1301        _ => result_var.to_string(),
1302    };
1303
1304    // Only wrap in String.trim/0 when the expression is actually a string.
1305    // Numeric expressions (e.g., length(...)) must not be wrapped.
1306    let is_numeric = is_numeric_expr(&field_expr);
1307    let trimmed_field_expr = if is_numeric {
1308        field_expr.clone()
1309    } else {
1310        format!("String.trim({field_expr})")
1311    };
1312
1313    // Detect whether the assertion field resolves to an array type so that
1314    // contains assertions can iterate items instead of calling to_string on the list.
1315    let field_is_array = assertion
1316        .field
1317        .as_deref()
1318        .filter(|f| !f.is_empty())
1319        .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1320
1321    match assertion.assertion_type.as_str() {
1322        "equals" => {
1323            if let Some(expected) = &assertion.value {
1324                let elixir_val = json_to_elixir(expected);
1325                // Apply String.trim only for string comparisons, not numeric ones.
1326                let is_string_expected = expected.is_string();
1327                if is_string_expected && !is_numeric {
1328                    let _ = writeln!(out, "      assert {trimmed_field_expr} == {elixir_val}");
1329                } else {
1330                    let _ = writeln!(out, "      assert {field_expr} == {elixir_val}");
1331                }
1332            }
1333        }
1334        "contains" => {
1335            if let Some(expected) = &assertion.value {
1336                let elixir_val = json_to_elixir(expected);
1337                if field_is_array && expected.is_string() {
1338                    // List of structs: check if any item's text representation contains the value.
1339                    let _ = writeln!(
1340                        out,
1341                        "      assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1342                    );
1343                } else {
1344                    // Use to_string() to handle atoms (enums) as well as strings
1345                    let _ = writeln!(
1346                        out,
1347                        "      assert String.contains?(to_string({field_expr}), {elixir_val})"
1348                    );
1349                }
1350            }
1351        }
1352        "contains_all" => {
1353            if let Some(values) = &assertion.values {
1354                for val in values {
1355                    let elixir_val = json_to_elixir(val);
1356                    if field_is_array && val.is_string() {
1357                        let _ = writeln!(
1358                            out,
1359                            "      assert Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1360                        );
1361                    } else {
1362                        let _ = writeln!(
1363                            out,
1364                            "      assert String.contains?(to_string({field_expr}), {elixir_val})"
1365                        );
1366                    }
1367                }
1368            }
1369        }
1370        "not_contains" => {
1371            if let Some(expected) = &assertion.value {
1372                let elixir_val = json_to_elixir(expected);
1373                if field_is_array && expected.is_string() {
1374                    let _ = writeln!(
1375                        out,
1376                        "      refute Enum.any?({field_expr}, fn item -> Enum.any?(alef_e2e_item_texts(item), &String.contains?(&1, {elixir_val})) end)"
1377                    );
1378                } else {
1379                    let _ = writeln!(
1380                        out,
1381                        "      refute String.contains?(to_string({field_expr}), {elixir_val})"
1382                    );
1383                }
1384            }
1385        }
1386        "not_empty" => {
1387            let _ = writeln!(out, "      assert {field_expr} != \"\"");
1388        }
1389        "is_empty" => {
1390            if is_numeric {
1391                // length(...) == 0
1392                let _ = writeln!(out, "      assert {field_expr} == 0");
1393            } else {
1394                // Handle nil (None) as empty
1395                let _ = writeln!(out, "      assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1396            }
1397        }
1398        "contains_any" => {
1399            if let Some(values) = &assertion.values {
1400                let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1401                let list_str = items.join(", ");
1402                let _ = writeln!(
1403                    out,
1404                    "      assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1405                );
1406            }
1407        }
1408        "greater_than" => {
1409            if let Some(val) = &assertion.value {
1410                let elixir_val = json_to_elixir(val);
1411                let _ = writeln!(out, "      assert {field_expr} > {elixir_val}");
1412            }
1413        }
1414        "less_than" => {
1415            if let Some(val) = &assertion.value {
1416                let elixir_val = json_to_elixir(val);
1417                let _ = writeln!(out, "      assert {field_expr} < {elixir_val}");
1418            }
1419        }
1420        "greater_than_or_equal" => {
1421            if let Some(val) = &assertion.value {
1422                let elixir_val = json_to_elixir(val);
1423                let _ = writeln!(out, "      assert {field_expr} >= {elixir_val}");
1424            }
1425        }
1426        "less_than_or_equal" => {
1427            if let Some(val) = &assertion.value {
1428                let elixir_val = json_to_elixir(val);
1429                let _ = writeln!(out, "      assert {field_expr} <= {elixir_val}");
1430            }
1431        }
1432        "starts_with" => {
1433            if let Some(expected) = &assertion.value {
1434                let elixir_val = json_to_elixir(expected);
1435                let _ = writeln!(out, "      assert String.starts_with?({field_expr}, {elixir_val})");
1436            }
1437        }
1438        "ends_with" => {
1439            if let Some(expected) = &assertion.value {
1440                let elixir_val = json_to_elixir(expected);
1441                let _ = writeln!(out, "      assert String.ends_with?({field_expr}, {elixir_val})");
1442            }
1443        }
1444        "min_length" => {
1445            if let Some(val) = &assertion.value {
1446                if let Some(n) = val.as_u64() {
1447                    let _ = writeln!(out, "      assert String.length({field_expr}) >= {n}");
1448                }
1449            }
1450        }
1451        "max_length" => {
1452            if let Some(val) = &assertion.value {
1453                if let Some(n) = val.as_u64() {
1454                    let _ = writeln!(out, "      assert String.length({field_expr}) <= {n}");
1455                }
1456            }
1457        }
1458        "count_min" => {
1459            if let Some(val) = &assertion.value {
1460                if let Some(n) = val.as_u64() {
1461                    let _ = writeln!(out, "      assert length({field_expr}) >= {n}");
1462                }
1463            }
1464        }
1465        "count_equals" => {
1466            if let Some(val) = &assertion.value {
1467                if let Some(n) = val.as_u64() {
1468                    let _ = writeln!(out, "      assert length({field_expr}) == {n}");
1469                }
1470            }
1471        }
1472        "is_true" => {
1473            let _ = writeln!(out, "      assert {field_expr} == true");
1474        }
1475        "is_false" => {
1476            let _ = writeln!(out, "      assert {field_expr} == false");
1477        }
1478        "method_result" => {
1479            if let Some(method_name) = &assertion.method {
1480                let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1481                let check = assertion.check.as_deref().unwrap_or("is_true");
1482                match check {
1483                    "equals" => {
1484                        if let Some(val) = &assertion.value {
1485                            let elixir_val = json_to_elixir(val);
1486                            let _ = writeln!(out, "      assert {call_expr} == {elixir_val}");
1487                        }
1488                    }
1489                    "is_true" => {
1490                        let _ = writeln!(out, "      assert {call_expr} == true");
1491                    }
1492                    "is_false" => {
1493                        let _ = writeln!(out, "      assert {call_expr} == false");
1494                    }
1495                    "greater_than_or_equal" => {
1496                        if let Some(val) = &assertion.value {
1497                            let n = val.as_u64().unwrap_or(0);
1498                            let _ = writeln!(out, "      assert {call_expr} >= {n}");
1499                        }
1500                    }
1501                    "count_min" => {
1502                        if let Some(val) = &assertion.value {
1503                            let n = val.as_u64().unwrap_or(0);
1504                            let _ = writeln!(out, "      assert length({call_expr}) >= {n}");
1505                        }
1506                    }
1507                    "contains" => {
1508                        if let Some(val) = &assertion.value {
1509                            let elixir_val = json_to_elixir(val);
1510                            let _ = writeln!(out, "      assert String.contains?({call_expr}, {elixir_val})");
1511                        }
1512                    }
1513                    "is_error" => {
1514                        let _ = writeln!(out, "      assert_raise RuntimeError, fn -> {call_expr} end");
1515                    }
1516                    other_check => {
1517                        panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1518                    }
1519                }
1520            } else {
1521                panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1522            }
1523        }
1524        "matches_regex" => {
1525            if let Some(expected) = &assertion.value {
1526                let elixir_val = json_to_elixir(expected);
1527                let _ = writeln!(out, "      assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1528            }
1529        }
1530        "not_error" => {
1531            // Already handled — the call would fail if it returned {:error, _}.
1532        }
1533        "error" => {
1534            // Handled at the test level.
1535        }
1536        other => {
1537            panic!("Elixir e2e generator: unsupported assertion type: {other}");
1538        }
1539    }
1540}
1541
1542/// Build an Elixir call expression for a `method_result` assertion on a tree-sitter result.
1543/// Maps method names to the appropriate `module_path` function calls.
1544fn build_elixir_method_call(
1545    result_var: &str,
1546    method_name: &str,
1547    args: Option<&serde_json::Value>,
1548    module_path: &str,
1549) -> String {
1550    match method_name {
1551        "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1552        "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1553        "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1554        "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1555        "contains_node_type" => {
1556            let node_type = args
1557                .and_then(|a| a.get("node_type"))
1558                .and_then(|v| v.as_str())
1559                .unwrap_or("");
1560            format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1561        }
1562        "find_nodes_by_type" => {
1563            let node_type = args
1564                .and_then(|a| a.get("node_type"))
1565                .and_then(|v| v.as_str())
1566                .unwrap_or("");
1567            format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1568        }
1569        "run_query" => {
1570            let query_source = args
1571                .and_then(|a| a.get("query_source"))
1572                .and_then(|v| v.as_str())
1573                .unwrap_or("");
1574            let language = args
1575                .and_then(|a| a.get("language"))
1576                .and_then(|v| v.as_str())
1577                .unwrap_or("");
1578            format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1579        }
1580        _ => format!("{module_path}.{method_name}({result_var})"),
1581    }
1582}
1583
1584/// Convert a category name to an Elixir module-safe PascalCase name.
1585fn elixir_module_name(category: &str) -> String {
1586    use heck::ToUpperCamelCase;
1587    category.to_upper_camel_case()
1588}
1589
1590/// Convert a `serde_json::Value` to an Elixir literal string.
1591fn json_to_elixir(value: &serde_json::Value) -> String {
1592    match value {
1593        serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1594        serde_json::Value::Bool(true) => "true".to_string(),
1595        serde_json::Value::Bool(false) => "false".to_string(),
1596        serde_json::Value::Number(n) => {
1597            // Elixir requires floats to have a decimal point and does not accept
1598            // `e+N` exponent notation. Strip the `+` and ensure there is a decimal
1599            // point before any `e` exponent marker (e.g. `1e-10` → `1.0e-10`).
1600            let s = n.to_string().replace("e+", "e");
1601            if s.contains('e') && !s.contains('.') {
1602                // Insert `.0` before the `e` so Elixir treats this as a float.
1603                s.replacen('e', ".0e", 1)
1604            } else {
1605                s
1606            }
1607        }
1608        serde_json::Value::Null => "nil".to_string(),
1609        serde_json::Value::Array(arr) => {
1610            let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1611            format!("[{}]", items.join(", "))
1612        }
1613        serde_json::Value::Object(map) => {
1614            let entries: Vec<String> = map
1615                .iter()
1616                .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1617                .collect();
1618            format!("%{{{}}}", entries.join(", "))
1619        }
1620    }
1621}
1622
1623/// Build an Elixir visitor map and add setup line. Returns the visitor variable name.
1624fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1625    use std::fmt::Write as FmtWrite;
1626    let mut visitor_obj = String::new();
1627    let _ = writeln!(visitor_obj, "%{{");
1628    for (method_name, action) in &visitor_spec.callbacks {
1629        emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1630    }
1631    let _ = writeln!(visitor_obj, "    }}");
1632
1633    setup_lines.push(format!("visitor = {visitor_obj}"));
1634    "visitor".to_string()
1635}
1636
1637/// Emit an Elixir visitor method for a callback action.
1638fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1639    use std::fmt::Write as FmtWrite;
1640
1641    // Elixir uses atom keys and handle_ prefix
1642    let handle_method = format!("handle_{}", &method_name[6..]); // strip "visit_" prefix
1643    // The Rust NIF bridge packages every visitor argument (`_ctx`, `_text`, …) into a
1644    // single map and invokes the user's anonymous function with that map. Generating
1645    // multi-arity functions like `fn(_ctx, _text) ->` therefore raised BadArityError
1646    // ("arity 2 called with 1 argument") at runtime. Generate arity-1 functions that
1647    // accept the args map (and ignore it) to match the bridge's calling convention.
1648
1649    // CustomTemplate needs to read from args; other actions can ignore it.
1650    let arg_binding = match action {
1651        CallbackAction::CustomTemplate { .. } => "args",
1652        _ => "_args",
1653    };
1654    let _ = writeln!(out, "      :{handle_method} => fn({arg_binding}) ->");
1655    match action {
1656        CallbackAction::Skip => {
1657            let _ = writeln!(out, "        :skip");
1658        }
1659        CallbackAction::Continue => {
1660            let _ = writeln!(out, "        :continue");
1661        }
1662        CallbackAction::PreserveHtml => {
1663            let _ = writeln!(out, "        :preserve_html");
1664        }
1665        CallbackAction::Custom { output } => {
1666            let escaped = escape_elixir(output);
1667            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
1668        }
1669        CallbackAction::CustomTemplate { template } => {
1670            // Build a <> concatenation expression so {key} placeholders are substituted
1671            // from the args map at runtime without embedding double-quoted strings inside
1672            // a double-quoted string literal.
1673            let expr = template_to_elixir_concat(template);
1674            let _ = writeln!(out, "        {{:custom, {expr}}}");
1675        }
1676    }
1677    let _ = writeln!(out, "      end,");
1678}
1679
1680/// Convert a template like `"_{text}_"` into an Elixir `<>` concat expression:
1681/// `"_" <> Map.get(args, "text", "") <> "_"`.
1682/// Static parts are escaped via `escape_elixir`; `{key}` placeholders become
1683/// `Map.get(args, "key", "")` lookups into the JSON-decoded args map.
1684fn template_to_elixir_concat(template: &str) -> String {
1685    let mut parts: Vec<String> = Vec::new();
1686    let mut static_buf = String::new();
1687    let mut chars = template.chars().peekable();
1688
1689    while let Some(ch) = chars.next() {
1690        if ch == '{' {
1691            let mut key = String::new();
1692            let mut closed = false;
1693            for kc in chars.by_ref() {
1694                if kc == '}' {
1695                    closed = true;
1696                    break;
1697                }
1698                key.push(kc);
1699            }
1700            if closed && !key.is_empty() {
1701                if !static_buf.is_empty() {
1702                    let escaped = escape_elixir(&static_buf);
1703                    parts.push(format!("\"{escaped}\""));
1704                    static_buf.clear();
1705                }
1706                let escaped_key = escape_elixir(&key);
1707                parts.push(format!("Map.get(args, \"{escaped_key}\", \"\")"));
1708            } else {
1709                static_buf.push('{');
1710                static_buf.push_str(&key);
1711                if !closed {
1712                    // unclosed brace — treat remaining as literal
1713                }
1714            }
1715        } else {
1716            static_buf.push(ch);
1717        }
1718    }
1719
1720    if !static_buf.is_empty() {
1721        let escaped = escape_elixir(&static_buf);
1722        parts.push(format!("\"{escaped}\""));
1723    }
1724
1725    if parts.is_empty() {
1726        return "\"\"".to_string();
1727    }
1728    parts.join(" <> ")
1729}
1730
1731fn fixture_has_elixir_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
1732    // HTTP fixtures are handled separately — not our concern here.
1733    if fixture.is_http_test() {
1734        return false;
1735    }
1736    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1737    let elixir_override = call_config
1738        .overrides
1739        .get("elixir")
1740        .or_else(|| e2e_config.call.overrides.get("elixir"));
1741    // When a client_factory is configured the fixture is callable via the client pattern.
1742    if elixir_override.and_then(|o| o.client_factory.as_deref()).is_some() {
1743        return true;
1744    }
1745    // Elixir bindings expose functions via module-level callables.
1746    // Like Python and Node, Elixir can call the base function directly without requiring
1747    // a language-specific override. The function can come from either the override or
1748    // the default [e2e.call] configuration.
1749    let function_from_override = elixir_override.and_then(|o| o.function.as_deref());
1750
1751    // If there's an override function, use it. Otherwise, Elixir can use the base function.
1752    function_from_override.is_some() || !call_config.function.is_empty()
1753}