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