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