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