Skip to main content

alef_e2e/codegen/
elixir.rs

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