Skip to main content

alef_e2e/codegen/
elixir.rs

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