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::AlefConfig;
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        alef_config: &AlefConfig,
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.
56        let function_name = if call.r#async && !base_function_name.ends_with("_async") {
57            format!("{base_function_name}_async")
58        } else {
59            base_function_name
60        };
61        let options_type = overrides.and_then(|o| o.options_type.clone());
62        let options_default_fn = overrides.and_then(|o| o.options_via.clone());
63        let empty_enum_fields = HashMap::new();
64        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
65        let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
66        let empty_atom_fields = std::collections::HashSet::new();
67        let handle_atom_list_fields = overrides
68            .map(|o| &o.handle_atom_list_fields)
69            .unwrap_or(&empty_atom_fields);
70        let result_var = &call.result_var;
71
72        // Check if any fixture in any group is an HTTP test.
73        let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
74        let has_nif_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| !f.is_http_test()));
75
76        // Resolve package reference (path or version) for the NIF dependency.
77        let pkg_ref = e2e_config.resolve_package(lang);
78        let pkg_path = if has_nif_tests {
79            pkg_ref.as_ref().and_then(|p| p.path.as_deref()).unwrap_or("")
80        } else {
81            ""
82        };
83
84        // Generate mix.exs. The dep atom must match the binding package's
85        // mix `app:` value, not the crate name. Use the configured
86        // `[elixir].app_name` (the same source the package's own mix.exs
87        // uses); fall back to the crate name only when unset. Without this,
88        // mix's path-dep resolution silently misroutes — the path-dep's
89        // own deps (notably `:rustler_precompiled`) never load during its
90        // compilation and the parent build fails with `RustlerPrecompiled
91        // is not loaded`.
92        let pkg_atom = alef_config.elixir_app_name();
93        files.push(GeneratedFile {
94            path: output_base.join("mix.exs"),
95            content: render_mix_exs(&pkg_atom, pkg_path, e2e_config.dep_mode, has_http_tests, has_nif_tests),
96            generated_header: false,
97        });
98
99        // Generate lib/e2e_elixir.ex — required so the mix project compiles.
100        files.push(GeneratedFile {
101            path: output_base.join("lib").join("e2e_elixir.ex"),
102            content: "defmodule E2eElixir do\n  @moduledoc false\nend\n".to_string(),
103            generated_header: false,
104        });
105
106        // Generate test_helper.exs.
107        files.push(GeneratedFile {
108            path: output_base.join("test").join("test_helper.exs"),
109            content: render_test_helper(has_http_tests),
110            generated_header: false,
111        });
112
113        // Generate test files per category.
114        for group in groups {
115            let active: Vec<&Fixture> = group
116                .fixtures
117                .iter()
118                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
119                .collect();
120
121            if active.is_empty() {
122                continue;
123            }
124
125            let filename = format!("{}_test.exs", sanitize_filename(&group.category));
126            let field_resolver = FieldResolver::new(
127                &e2e_config.fields,
128                &e2e_config.fields_optional,
129                &e2e_config.result_fields,
130                &e2e_config.fields_array,
131            );
132            let content = render_test_file(
133                &group.category,
134                &active,
135                e2e_config,
136                &module_path,
137                &function_name,
138                result_var,
139                &e2e_config.call.args,
140                &field_resolver,
141                options_type.as_deref(),
142                options_default_fn.as_deref(),
143                enum_fields,
144                handle_struct_type.as_deref(),
145                handle_atom_list_fields,
146            );
147            files.push(GeneratedFile {
148                path: output_base.join("test").join(filename),
149                content,
150                generated_header: true,
151            });
152        }
153
154        Ok(files)
155    }
156
157    fn language_name(&self) -> &'static str {
158        "elixir"
159    }
160}
161
162fn render_test_helper(has_http_tests: bool) -> String {
163    if has_http_tests {
164        r#"ExUnit.start()
165
166# Spawn mock-server binary and set MOCK_SERVER_URL for all tests.
167mock_server_bin = Path.expand("../../rust/target/release/mock-server", __DIR__)
168fixtures_dir = Path.expand("../../../fixtures", __DIR__)
169
170if File.exists?(mock_server_bin) do
171  port = Port.open({:spawn_executable, mock_server_bin}, [
172    :binary,
173    :line,
174    args: [fixtures_dir]
175  ])
176  receive do
177    {^port, {:data, {:eol, "MOCK_SERVER_URL=" <> url}}} ->
178      System.put_env("MOCK_SERVER_URL", url)
179  after
180    30_000 ->
181      raise "mock-server startup timeout"
182  end
183end
184"#
185        .to_string()
186    } else {
187        "ExUnit.start()\n".to_string()
188    }
189}
190
191fn render_mix_exs(
192    pkg_name: &str,
193    pkg_path: &str,
194    dep_mode: crate::config::DependencyMode,
195    has_http_tests: bool,
196    has_nif_tests: bool,
197) -> String {
198    let mut out = String::new();
199    let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
200    let _ = writeln!(out, "  use Mix.Project");
201    let _ = writeln!(out);
202    let _ = writeln!(out, "  def project do");
203    let _ = writeln!(out, "    [");
204    let _ = writeln!(out, "      app: :e2e_elixir,");
205    let _ = writeln!(out, "      version: \"0.1.0\",");
206    let _ = writeln!(out, "      elixir: \"~> 1.14\",");
207    let _ = writeln!(out, "      deps: deps()");
208    let _ = writeln!(out, "    ]");
209    let _ = writeln!(out, "  end");
210    let _ = writeln!(out);
211    let _ = writeln!(out, "  defp deps do");
212    let _ = writeln!(out, "    [");
213
214    // Build the list of deps, then join with commas to avoid double-commas.
215    let mut deps: Vec<String> = Vec::new();
216
217    // Add the binding NIF dependency when there are non-HTTP tests.
218    if has_nif_tests && !pkg_path.is_empty() {
219        let pkg_atom = pkg_name;
220        let nif_dep = match dep_mode {
221            crate::config::DependencyMode::Local => {
222                format!("      {{:{pkg_atom}, path: \"{pkg_path}\"}}")
223            }
224            crate::config::DependencyMode::Registry => {
225                // Registry mode: pkg_path is repurposed as the version string.
226                format!("      {{:{pkg_atom}, \"{pkg_path}\"}}")
227            }
228        };
229        deps.push(nif_dep);
230        // rustler_precompiled provides the precompiled NIF loader.
231        deps.push(format!(
232            "      {{:rustler_precompiled, \"{rp}\"}}",
233            rp = tv::hex::RUSTLER_PRECOMPILED
234        ));
235        // rustler must be a direct dep in the consumer project for force_build to work.
236        deps.push(format!(
237            "      {{:rustler, \"{rustler}\", optional: true, runtime: false}}",
238            rustler = tv::hex::RUSTLER
239        ));
240    }
241
242    // Add Req + Jason for HTTP testing.
243    if has_http_tests {
244        deps.push(format!("      {{:req, \"{req}\"}}", req = tv::hex::REQ));
245        deps.push(format!("      {{:jason, \"{jason}\"}}", jason = tv::hex::JASON));
246    }
247
248    let _ = writeln!(out, "{}", deps.join(",\n"));
249    let _ = writeln!(out, "    ]");
250    let _ = writeln!(out, "  end");
251    let _ = writeln!(out, "end");
252    out
253}
254
255#[allow(clippy::too_many_arguments)]
256fn render_test_file(
257    category: &str,
258    fixtures: &[&Fixture],
259    e2e_config: &E2eConfig,
260    module_path: &str,
261    function_name: &str,
262    result_var: &str,
263    args: &[crate::config::ArgMapping],
264    field_resolver: &FieldResolver,
265    options_type: Option<&str>,
266    options_default_fn: Option<&str>,
267    enum_fields: &HashMap<String, String>,
268    handle_struct_type: Option<&str>,
269    handle_atom_list_fields: &std::collections::HashSet<String>,
270) -> String {
271    let mut out = String::new();
272    out.push_str(&hash::header(CommentStyle::Hash));
273    let _ = writeln!(out, "# E2e tests for category: {category}");
274    let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
275
276    // Add client helper when there are HTTP fixtures in this group.
277    let has_http = fixtures.iter().any(|f| f.is_http_test());
278
279    // Use async: false for NIF tests — concurrent Tokio runtimes created by DirtyCpu NIFs
280    // on ARM64 macOS cause SIGBUS when tests run in parallel. HTTP-only tests can stay async.
281    let async_flag = if has_http { "true" } else { "false" };
282    let _ = writeln!(out, "  use ExUnit.Case, async: {async_flag}");
283
284    if has_http {
285        let _ = writeln!(out);
286        let _ = writeln!(out, "  defp mock_server_url do");
287        let _ = writeln!(
288            out,
289            "    System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
290        );
291        let _ = writeln!(out, "  end");
292    }
293
294    let _ = writeln!(out);
295
296    for (i, fixture) in fixtures.iter().enumerate() {
297        if let Some(http) = &fixture.http {
298            render_http_test_case(&mut out, fixture, http);
299        } else {
300            render_test_case(
301                &mut out,
302                fixture,
303                e2e_config,
304                module_path,
305                function_name,
306                result_var,
307                args,
308                field_resolver,
309                options_type,
310                options_default_fn,
311                enum_fields,
312                handle_struct_type,
313                handle_atom_list_fields,
314            );
315        }
316        if i + 1 < fixtures.len() {
317            let _ = writeln!(out);
318        }
319    }
320
321    let _ = writeln!(out, "end");
322    out
323}
324
325// ---------------------------------------------------------------------------
326// HTTP test rendering
327// ---------------------------------------------------------------------------
328
329/// HTTP methods that Finch (Req's underlying HTTP client) does not support.
330/// Tests using these methods are emitted with `@tag :skip` so they don't fail.
331const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
332
333/// HTTP methods that Req exposes as convenience functions.
334/// All others must be called via `Req.request(method: :METHOD, ...)`.
335const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
336
337/// Thin renderer that emits ExUnit `describe` + `test` blocks targeting a mock
338/// server via `Req`. Satisfies [`client::TestClientRenderer`] so the shared
339/// [`client::http_call::render_http_test`] driver drives the call sequence.
340struct ElixirTestClientRenderer<'a> {
341    /// The fixture id is needed in [`render_call`] to build the mock server URL
342    /// (`mock_server_url()/fixtures/<id>`).
343    fixture_id: &'a str,
344    /// Expected response status, needed to disable Req's redirect-following for 3xx.
345    expected_status: u16,
346}
347
348impl<'a> client::TestClientRenderer for ElixirTestClientRenderer<'a> {
349    fn language_name(&self) -> &'static str {
350        "elixir"
351    }
352
353    /// Emit `describe "{fn_name}" do` + inner `test "METHOD PATH - description" do`.
354    ///
355    /// When `skip_reason` is `Some`, emit `@tag :skip` before the test block so
356    /// ExUnit skips it; the shared driver short-circuits before emitting any
357    /// assertions and then calls `render_test_close` for symmetry.
358    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
359        let escaped_description = description.replace('"', "\\\"");
360        let _ = writeln!(out, "  describe \"{fn_name}\" do");
361        if skip_reason.is_some() {
362            let _ = writeln!(out, "    @tag :skip");
363        }
364        let _ = writeln!(out, "    test \"{escaped_description}\" do");
365    }
366
367    /// Close the inner `test` block and the outer `describe` block.
368    fn render_test_close(&self, out: &mut String) {
369        let _ = writeln!(out, "    end");
370        let _ = writeln!(out, "  end");
371    }
372
373    /// Emit a `Req` request to the mock server using `mock_server_url()/fixtures/<id>`.
374    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
375        let method = ctx.method.to_lowercase();
376        let mut opts: Vec<String> = Vec::new();
377
378        if let Some(body) = ctx.body {
379            let elixir_val = json_to_elixir(body);
380            opts.push(format!("json: {elixir_val}"));
381        }
382
383        if !ctx.headers.is_empty() {
384            let header_pairs: Vec<String> = ctx
385                .headers
386                .iter()
387                .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
388                .collect();
389            opts.push(format!("headers: [{}]", header_pairs.join(", ")));
390        }
391
392        if !ctx.cookies.is_empty() {
393            let cookie_str = ctx
394                .cookies
395                .iter()
396                .map(|(k, v)| format!("{k}={v}"))
397                .collect::<Vec<_>>()
398                .join("; ");
399            opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
400        }
401
402        if !ctx.query_params.is_empty() {
403            let pairs: Vec<String> = ctx
404                .query_params
405                .iter()
406                .map(|(k, v)| {
407                    let val_str = match v {
408                        serde_json::Value::String(s) => s.clone(),
409                        other => other.to_string(),
410                    };
411                    format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
412                })
413                .collect();
414            opts.push(format!("params: [{}]", pairs.join(", ")));
415        }
416
417        // When the expected response is a redirect (3xx), disable automatic redirect
418        // following so the test can assert the redirect status and Location header.
419        if (300..400).contains(&self.expected_status) {
420            opts.push("redirect: false".to_string());
421        }
422
423        let fixture_id = escape_elixir(self.fixture_id);
424        let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{fixture_id}\"");
425
426        if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
427            if opts.is_empty() {
428                let _ = writeln!(out, "      {{:ok, response}} = Req.{method}(url: {url_expr})");
429            } else {
430                let opts_str = opts.join(", ");
431                let _ = writeln!(
432                    out,
433                    "      {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
434                );
435            }
436        } else {
437            opts.insert(0, format!("method: :{method}"));
438            opts.insert(1, format!("url: {url_expr}"));
439            let opts_str = opts.join(", ");
440            let _ = writeln!(out, "      {{:ok, response}} = Req.request({opts_str})");
441        }
442    }
443
444    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
445        let _ = writeln!(out, "      assert {response_var}.status == {status}");
446    }
447
448    /// Emit a header assertion.
449    ///
450    /// Handles the special tokens `<<present>>`, `<<absent>>`, `<<uuid>>`.
451    /// Skips the `connection` header (hop-by-hop, stripped by Req/Mint).
452    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
453        let header_key = name.to_lowercase();
454        // Req (via Mint) strips hop-by-hop headers; asserting on them is meaningless.
455        if header_key == "connection" {
456            return;
457        }
458        let key_lit = format!("\"{}\"", escape_elixir(&header_key));
459        let get_header_expr = format!(
460            "Enum.find_value({response_var}.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
461        );
462        match expected {
463            "<<present>>" => {
464                let _ = writeln!(out, "      assert {get_header_expr} != nil");
465            }
466            "<<absent>>" => {
467                let _ = writeln!(out, "      assert {get_header_expr} == nil");
468            }
469            "<<uuid>>" => {
470                let var = sanitize_ident(&header_key);
471                let _ = writeln!(out, "      header_val_{var} = {get_header_expr}");
472                let _ = writeln!(
473                    out,
474                    "      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}))"
475                );
476            }
477            literal => {
478                let val_lit = format!("\"{}\"", escape_elixir(literal));
479                let _ = writeln!(out, "      assert {get_header_expr} == {val_lit}");
480            }
481        }
482    }
483
484    /// Emit a full JSON body equality assertion.
485    ///
486    /// Req auto-decodes `application/json` bodies; when the response body is a
487    /// binary (non-JSON content type), decode it with `Jason.decode!` first.
488    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
489        let elixir_val = json_to_elixir(expected);
490        match expected {
491            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
492                let _ = writeln!(
493                    out,
494                    "      body_decoded = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
495                );
496                let _ = writeln!(out, "      assert body_decoded == {elixir_val}");
497            }
498            _ => {
499                let _ = writeln!(out, "      assert {response_var}.body == {elixir_val}");
500            }
501        }
502    }
503
504    /// Emit partial body assertions: one assertion per key in `expected`.
505    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
506        if let Some(obj) = expected.as_object() {
507            let _ = writeln!(
508                out,
509                "      decoded_body = if is_binary({response_var}.body), do: Jason.decode!({response_var}.body), else: {response_var}.body"
510            );
511            for (key, val) in obj {
512                let key_lit = format!("\"{}\"", escape_elixir(key));
513                let elixir_val = json_to_elixir(val);
514                let _ = writeln!(out, "      assert decoded_body[{key_lit}] == {elixir_val}");
515            }
516        }
517    }
518
519    /// Emit validation-error assertions, checking each expected `msg` appears in
520    /// the encoded response body.
521    fn render_assert_validation_errors(
522        &self,
523        out: &mut String,
524        response_var: &str,
525        errors: &[ValidationErrorExpectation],
526    ) {
527        for err in errors {
528            let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
529            let _ = writeln!(
530                out,
531                "      assert String.contains?(Jason.encode!({response_var}.body), {msg_lit})"
532            );
533        }
534    }
535}
536
537/// Render an ExUnit `describe` + `test` block for an HTTP server test fixture.
538///
539/// Delegates to [`client::http_call::render_http_test`] after the one
540/// Elixir-specific pre-condition: HTTP methods unsupported by Finch (the
541/// underlying Req adapter) are emitted with `@tag :skip` directly.
542fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
543    let method = http.request.method.to_uppercase();
544
545    // Finch does not support TRACE/CONNECT — emit a skipped test stub directly
546    // rather than routing through the shared driver, which would assert on the
547    // response and fail.
548    if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
549        let test_name = sanitize_ident(&fixture.id);
550        let description = fixture.description.replace('"', "\\\"");
551        let path = &http.request.path;
552        let _ = writeln!(out, "  describe \"{test_name}\" do");
553        let _ = writeln!(out, "    @tag :skip");
554        let _ = writeln!(out, "    test \"{method} {path} - {description}\" do");
555        let _ = writeln!(out, "    end");
556        let _ = writeln!(out, "  end");
557        return;
558    }
559
560    let renderer = ElixirTestClientRenderer {
561        fixture_id: &fixture.id,
562        expected_status: http.expected_response.status_code,
563    };
564    client::http_call::render_http_test(out, &renderer, fixture);
565}
566
567// ---------------------------------------------------------------------------
568// Function-call test rendering
569// ---------------------------------------------------------------------------
570
571#[allow(clippy::too_many_arguments)]
572fn render_test_case(
573    out: &mut String,
574    fixture: &Fixture,
575    e2e_config: &E2eConfig,
576    default_module_path: &str,
577    default_function_name: &str,
578    default_result_var: &str,
579    args: &[crate::config::ArgMapping],
580    field_resolver: &FieldResolver,
581    options_type: Option<&str>,
582    options_default_fn: Option<&str>,
583    enum_fields: &HashMap<String, String>,
584    handle_struct_type: Option<&str>,
585    handle_atom_list_fields: &std::collections::HashSet<String>,
586) {
587    let test_name = sanitize_ident(&fixture.id);
588    let description = fixture.description.replace('"', "\\\"");
589
590    // Non-HTTP non-mock_response fixtures (e.g. AsyncAPI, WebSocket, OpenRPC
591    // protocol-only fixtures) cannot be tested via the configured `[e2e.call]`
592    // function when the binding does not expose it. Emit a documented `@tag :skip`
593    // test so the suite stays compilable. HTTP fixtures dispatch via render_http_test_case
594    // and never reach here.
595    if fixture.mock_response.is_none() {
596        let _ = writeln!(out, "  describe \"{test_name}\" do");
597        let _ = writeln!(out, "    @tag :skip");
598        let _ = writeln!(out, "    test \"{description}\" do");
599        let _ = writeln!(
600            out,
601            "      # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
602        );
603        let _ = writeln!(out, "      :ok");
604        let _ = writeln!(out, "    end");
605        let _ = writeln!(out, "  end");
606        return;
607    }
608
609    // Resolve per-fixture call config (falls back to default if fixture.call is None).
610    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
611    let lang = "elixir";
612    let call_overrides = call_config.overrides.get(lang);
613
614    // Compute module_path and function_name from the resolved call config,
615    // applying Elixir-specific PascalCase conversion.
616    let (module_path, function_name, result_var) = if fixture.call.is_some() {
617        let raw_module = call_overrides
618            .and_then(|o| o.module.as_ref())
619            .cloned()
620            .unwrap_or_else(|| call_config.module.clone());
621        let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
622        {
623            raw_module.clone()
624        } else {
625            elixir_module_name(&raw_module)
626        };
627        let base_fn = call_overrides
628            .and_then(|o| o.function.as_ref())
629            .cloned()
630            .unwrap_or_else(|| call_config.function.clone());
631        let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
632            format!("{base_fn}_async")
633        } else {
634            base_fn
635        };
636        (resolved_module, resolved_fn, call_config.result_var.clone())
637    } else {
638        (
639            default_module_path.to_string(),
640            default_function_name.to_string(),
641            default_result_var.to_string(),
642        )
643    };
644
645    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
646
647    // When the fixture uses a named call, use the args and options from that call's config.
648    let (
649        effective_args,
650        effective_options_type,
651        effective_options_default_fn,
652        effective_enum_fields,
653        effective_handle_struct_type,
654        effective_handle_atom_list_fields,
655    );
656    let empty_enum_fields_local: HashMap<String, String>;
657    let empty_atom_fields_local: std::collections::HashSet<String>;
658    let (
659        resolved_args,
660        resolved_options_type,
661        resolved_options_default_fn,
662        resolved_enum_fields_ref,
663        resolved_handle_struct_type,
664        resolved_handle_atom_list_fields_ref,
665    ) = if fixture.call.is_some() {
666        let co = call_config.overrides.get(lang);
667        effective_args = call_config.args.as_slice();
668        effective_options_type = co.and_then(|o| o.options_type.as_deref());
669        effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
670        empty_enum_fields_local = HashMap::new();
671        effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
672        effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
673        empty_atom_fields_local = std::collections::HashSet::new();
674        effective_handle_atom_list_fields = co
675            .map(|o| &o.handle_atom_list_fields)
676            .unwrap_or(&empty_atom_fields_local);
677        (
678            effective_args,
679            effective_options_type,
680            effective_options_default_fn,
681            effective_enum_fields,
682            effective_handle_struct_type,
683            effective_handle_atom_list_fields,
684        )
685    } else {
686        (
687            args as &[_],
688            options_type,
689            options_default_fn,
690            enum_fields,
691            handle_struct_type,
692            handle_atom_list_fields,
693        )
694    };
695
696    let (mut setup_lines, args_str) = build_args_and_setup(
697        &fixture.input,
698        resolved_args,
699        &module_path,
700        resolved_options_type,
701        resolved_options_default_fn,
702        resolved_enum_fields_ref,
703        &fixture.id,
704        resolved_handle_struct_type,
705        resolved_handle_atom_list_fields_ref,
706    );
707
708    // Build visitor if present — must happen before emitting setup_lines so the
709    // visitor setup line is included in the loop below.
710    let final_args = if let Some(visitor_spec) = &fixture.visitor {
711        let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
712        format!("{args_str}, {visitor_var}")
713    } else {
714        args_str
715    };
716
717    let _ = writeln!(out, "  describe \"{test_name}\" do");
718    let _ = writeln!(out, "    test \"{description}\" do");
719
720    for line in &setup_lines {
721        let _ = writeln!(out, "      {line}");
722    }
723
724    let returns_result = call_config.returns_result;
725
726    if expects_error {
727        if returns_result {
728            let _ = writeln!(
729                out,
730                "      assert {{:error, _}} = {module_path}.{function_name}({final_args})"
731            );
732        } else {
733            // Non-Result function — just call and discard; error detection not meaningful.
734            let _ = writeln!(out, "      _result = {module_path}.{function_name}({final_args})");
735        }
736        let _ = writeln!(out, "    end");
737        let _ = writeln!(out, "  end");
738        return;
739    }
740
741    if returns_result {
742        let _ = writeln!(
743            out,
744            "      {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
745        );
746    } else {
747        // Non-Result function returns value directly (e.g., bool, String).
748        let _ = writeln!(out, "      {result_var} = {module_path}.{function_name}({final_args})");
749    }
750
751    for assertion in &fixture.assertions {
752        render_assertion(out, assertion, &result_var, field_resolver, &module_path);
753    }
754
755    let _ = writeln!(out, "    end");
756    let _ = writeln!(out, "  end");
757}
758
759/// Build setup lines (e.g. handle creation) and the argument list for the function call.
760///
761/// Returns `(setup_lines, args_string)`.
762#[allow(clippy::too_many_arguments)]
763fn build_args_and_setup(
764    input: &serde_json::Value,
765    args: &[crate::config::ArgMapping],
766    module_path: &str,
767    options_type: Option<&str>,
768    options_default_fn: Option<&str>,
769    enum_fields: &HashMap<String, String>,
770    fixture_id: &str,
771    _handle_struct_type: Option<&str>,
772    _handle_atom_list_fields: &std::collections::HashSet<String>,
773) -> (Vec<String>, String) {
774    if args.is_empty() {
775        return (Vec::new(), json_to_elixir(input));
776    }
777
778    let mut setup_lines: Vec<String> = Vec::new();
779    let mut parts: Vec<String> = Vec::new();
780
781    for arg in args {
782        if arg.arg_type == "mock_url" {
783            setup_lines.push(format!(
784                "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
785                arg.name,
786            ));
787            parts.push(arg.name.clone());
788            continue;
789        }
790
791        if arg.arg_type == "handle" {
792            // Generate a create_{name} call using {:ok, name} = ... pattern.
793            // The NIF now accepts config as an optional JSON string (not a NifStruct/NifMap)
794            // so that partial maps work: serde_json::from_str respects #[serde(default)].
795            let constructor_name = format!("create_{}", arg.name.to_snake_case());
796            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
797            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
798            let name = &arg.name;
799            if config_value.is_null()
800                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
801            {
802                setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
803            } else {
804                // Serialize the config map to a JSON string with Jason so that Rust can
805                // deserialize it with serde_json and apply field defaults for missing keys.
806                let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
807                let escaped = escape_elixir(&json_str);
808                setup_lines.push(format!("{name}_config = \"{escaped}\""));
809                setup_lines.push(format!(
810                    "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
811                ));
812            }
813            parts.push(arg.name.clone());
814            continue;
815        }
816
817        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
818        let val = input.get(field);
819        match val {
820            None | Some(serde_json::Value::Null) if arg.optional => {
821                // Elixir functions have fixed positional arity — pass nil for optional args
822                // rather than skipping them, so the call site has the correct arity.
823                parts.push("nil".to_string());
824                continue;
825            }
826            None | Some(serde_json::Value::Null) => {
827                // Required arg with no fixture value: pass a language-appropriate default.
828                let default_val = match arg.arg_type.as_str() {
829                    "string" => "\"\"".to_string(),
830                    "int" | "integer" => "0".to_string(),
831                    "float" | "number" => "0.0".to_string(),
832                    "bool" | "boolean" => "false".to_string(),
833                    _ => "nil".to_string(),
834                };
835                parts.push(default_val);
836            }
837            Some(v) => {
838                // For file_path args, prepend the path to the test_documents directory
839                // relative to the e2e/elixir/ directory where `mix test` runs.
840                if arg.arg_type == "file_path" {
841                    if let Some(path_str) = v.as_str() {
842                        let full_path = format!("../../test_documents/{path_str}");
843                        parts.push(format!("\"{}\"", escape_elixir(&full_path)));
844                        continue;
845                    }
846                }
847                // For bytes args, use File.read! for file paths and Base.decode64! for base64.
848                // Inline text (starts with '<', '{', '[' or contains spaces) is used as-is (UTF-8 binary).
849                if arg.arg_type == "bytes" {
850                    if let Some(raw) = v.as_str() {
851                        let var_name = &arg.name;
852                        if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
853                            // Inline text — use as a binary string.
854                            parts.push(format!("\"{}\"", escape_elixir(raw)));
855                        } else {
856                            let first = raw.chars().next().unwrap_or('\0');
857                            let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
858                                && raw
859                                    .find('/')
860                                    .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
861                            if is_file_path {
862                                // Looks like "dir/file.ext" — read from test_documents.
863                                let full_path = format!("../../test_documents/{raw}");
864                                let escaped = escape_elixir(&full_path);
865                                setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
866                                parts.push(var_name.to_string());
867                            } else {
868                                // Treat as base64-encoded binary.
869                                setup_lines.push(format!(
870                                    "{var_name} = Base.decode64!(\"{}\", padding: false)",
871                                    escape_elixir(raw)
872                                ));
873                                parts.push(var_name.to_string());
874                            }
875                        }
876                        continue;
877                    }
878                }
879                // For json_object args with options_type+options_via, build a proper struct.
880                if arg.arg_type == "json_object" && !v.is_null() {
881                    if let (Some(_opts_type), Some(options_fn), Some(obj)) =
882                        (options_type, options_default_fn, v.as_object())
883                    {
884                        // Add setup line to initialize options from default function.
885                        let options_var = "options";
886                        setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
887
888                        // For each field in the options object, add a struct update line.
889                        for (k, vv) in obj.iter() {
890                            let snake_key = k.to_snake_case();
891                            let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
892                                if let Some(s) = vv.as_str() {
893                                    let snake_val = s.to_snake_case();
894                                    // Use atom for enum values, not string
895                                    format!(":{snake_val}")
896                                } else {
897                                    json_to_elixir(vv)
898                                }
899                            } else {
900                                json_to_elixir(vv)
901                            };
902                            setup_lines.push(format!(
903                                "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
904                            ));
905                        }
906
907                        // Push the variable name as the argument.
908                        parts.push(options_var.to_string());
909                        continue;
910                    }
911                    // When there's no options_type+options_via, the Elixir NIF expects a JSON
912                    // string (Option<String> decoded by serde_json) rather than an Elixir map.
913                    // Serialize the JSON value to a string literal here.
914                    if !v.is_null() {
915                        let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
916                        let escaped = escape_elixir(&json_str);
917                        parts.push(format!("\"{escaped}\""));
918                        continue;
919                    }
920                }
921                parts.push(json_to_elixir(v));
922            }
923        }
924    }
925
926    (setup_lines, parts.join(", "))
927}
928
929/// Returns true if the field expression is a numeric/integer expression
930/// (e.g., a `length(...)` call) rather than a string.
931fn is_numeric_expr(field_expr: &str) -> bool {
932    field_expr.starts_with("length(")
933}
934
935fn render_assertion(
936    out: &mut String,
937    assertion: &Assertion,
938    result_var: &str,
939    field_resolver: &FieldResolver,
940    module_path: &str,
941) {
942    // Handle synthetic / derived fields before the is_valid_for_result check
943    // so they are never treated as struct field accesses on the result.
944    if let Some(f) = &assertion.field {
945        match f.as_str() {
946            "chunks_have_content" => {
947                let pred =
948                    format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
949                match assertion.assertion_type.as_str() {
950                    "is_true" => {
951                        let _ = writeln!(out, "      assert {pred}");
952                    }
953                    "is_false" => {
954                        let _ = writeln!(out, "      refute {pred}");
955                    }
956                    _ => {
957                        let _ = writeln!(
958                            out,
959                            "      # skipped: unsupported assertion type on synthetic field '{f}'"
960                        );
961                    }
962                }
963                return;
964            }
965            "chunks_have_embeddings" => {
966                let pred = format!(
967                    "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
968                );
969                match assertion.assertion_type.as_str() {
970                    "is_true" => {
971                        let _ = writeln!(out, "      assert {pred}");
972                    }
973                    "is_false" => {
974                        let _ = writeln!(out, "      refute {pred}");
975                    }
976                    _ => {
977                        let _ = writeln!(
978                            out,
979                            "      # skipped: unsupported assertion type on synthetic field '{f}'"
980                        );
981                    }
982                }
983                return;
984            }
985            // ---- EmbedResponse virtual fields ----
986            // embed_texts returns [[float]] in Elixir — no wrapper struct.
987            // result_var is the embedding matrix; use it directly.
988            "embeddings" => {
989                match assertion.assertion_type.as_str() {
990                    "count_equals" => {
991                        if let Some(val) = &assertion.value {
992                            let ex_val = json_to_elixir(val);
993                            let _ = writeln!(out, "      assert length({result_var}) == {ex_val}");
994                        }
995                    }
996                    "count_min" => {
997                        if let Some(val) = &assertion.value {
998                            let ex_val = json_to_elixir(val);
999                            let _ = writeln!(out, "      assert length({result_var}) >= {ex_val}");
1000                        }
1001                    }
1002                    "not_empty" => {
1003                        let _ = writeln!(out, "      assert {result_var} != []");
1004                    }
1005                    "is_empty" => {
1006                        let _ = writeln!(out, "      assert {result_var} == []");
1007                    }
1008                    _ => {
1009                        let _ = writeln!(
1010                            out,
1011                            "      # skipped: unsupported assertion type on synthetic field 'embeddings'"
1012                        );
1013                    }
1014                }
1015                return;
1016            }
1017            "embedding_dimensions" => {
1018                let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
1019                match assertion.assertion_type.as_str() {
1020                    "equals" => {
1021                        if let Some(val) = &assertion.value {
1022                            let ex_val = json_to_elixir(val);
1023                            let _ = writeln!(out, "      assert {expr} == {ex_val}");
1024                        }
1025                    }
1026                    "greater_than" => {
1027                        if let Some(val) = &assertion.value {
1028                            let ex_val = json_to_elixir(val);
1029                            let _ = writeln!(out, "      assert {expr} > {ex_val}");
1030                        }
1031                    }
1032                    _ => {
1033                        let _ = writeln!(
1034                            out,
1035                            "      # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1036                        );
1037                    }
1038                }
1039                return;
1040            }
1041            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1042                let pred = match f.as_str() {
1043                    "embeddings_valid" => {
1044                        format!("Enum.all?({result_var}, fn e -> e != [] end)")
1045                    }
1046                    "embeddings_finite" => {
1047                        format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1048                    }
1049                    "embeddings_non_zero" => {
1050                        format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1051                    }
1052                    "embeddings_normalized" => {
1053                        format!(
1054                            "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)"
1055                        )
1056                    }
1057                    _ => unreachable!(),
1058                };
1059                match assertion.assertion_type.as_str() {
1060                    "is_true" => {
1061                        let _ = writeln!(out, "      assert {pred}");
1062                    }
1063                    "is_false" => {
1064                        let _ = writeln!(out, "      refute {pred}");
1065                    }
1066                    _ => {
1067                        let _ = writeln!(
1068                            out,
1069                            "      # skipped: unsupported assertion type on synthetic field '{f}'"
1070                        );
1071                    }
1072                }
1073                return;
1074            }
1075            // ---- keywords / keywords_count ----
1076            // Elixir ExtractionResult does not expose extracted_keywords; skip.
1077            "keywords" | "keywords_count" => {
1078                let _ = writeln!(
1079                    out,
1080                    "      # skipped: field '{f}' not available on Elixir ExtractionResult"
1081                );
1082                return;
1083            }
1084            _ => {}
1085        }
1086    }
1087
1088    // Skip assertions on fields that don't exist on the result type.
1089    if let Some(f) = &assertion.field {
1090        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1091            let _ = writeln!(out, "      # skipped: field '{f}' not available on result type");
1092            return;
1093        }
1094    }
1095
1096    let field_expr = match &assertion.field {
1097        Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1098        _ => result_var.to_string(),
1099    };
1100
1101    // Only wrap in String.trim/0 when the expression is actually a string.
1102    // Numeric expressions (e.g., length(...)) must not be wrapped.
1103    let is_numeric = is_numeric_expr(&field_expr);
1104    let trimmed_field_expr = if is_numeric {
1105        field_expr.clone()
1106    } else {
1107        format!("String.trim({field_expr})")
1108    };
1109
1110    match assertion.assertion_type.as_str() {
1111        "equals" => {
1112            if let Some(expected) = &assertion.value {
1113                let elixir_val = json_to_elixir(expected);
1114                // Apply String.trim only for string comparisons, not numeric ones.
1115                let is_string_expected = expected.is_string();
1116                if is_string_expected && !is_numeric {
1117                    let _ = writeln!(out, "      assert {trimmed_field_expr} == {elixir_val}");
1118                } else {
1119                    let _ = writeln!(out, "      assert {field_expr} == {elixir_val}");
1120                }
1121            }
1122        }
1123        "contains" => {
1124            if let Some(expected) = &assertion.value {
1125                let elixir_val = json_to_elixir(expected);
1126                // Use to_string() to handle atoms (enums) as well as strings
1127                let _ = writeln!(
1128                    out,
1129                    "      assert String.contains?(to_string({field_expr}), {elixir_val})"
1130                );
1131            }
1132        }
1133        "contains_all" => {
1134            if let Some(values) = &assertion.values {
1135                for val in values {
1136                    let elixir_val = json_to_elixir(val);
1137                    let _ = writeln!(
1138                        out,
1139                        "      assert String.contains?(to_string({field_expr}), {elixir_val})"
1140                    );
1141                }
1142            }
1143        }
1144        "not_contains" => {
1145            if let Some(expected) = &assertion.value {
1146                let elixir_val = json_to_elixir(expected);
1147                let _ = writeln!(
1148                    out,
1149                    "      refute String.contains?(to_string({field_expr}), {elixir_val})"
1150                );
1151            }
1152        }
1153        "not_empty" => {
1154            let _ = writeln!(out, "      assert {field_expr} != \"\"");
1155        }
1156        "is_empty" => {
1157            if is_numeric {
1158                // length(...) == 0
1159                let _ = writeln!(out, "      assert {field_expr} == 0");
1160            } else {
1161                // Handle nil (None) as empty
1162                let _ = writeln!(out, "      assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1163            }
1164        }
1165        "contains_any" => {
1166            if let Some(values) = &assertion.values {
1167                let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1168                let list_str = items.join(", ");
1169                let _ = writeln!(
1170                    out,
1171                    "      assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1172                );
1173            }
1174        }
1175        "greater_than" => {
1176            if let Some(val) = &assertion.value {
1177                let elixir_val = json_to_elixir(val);
1178                let _ = writeln!(out, "      assert {field_expr} > {elixir_val}");
1179            }
1180        }
1181        "less_than" => {
1182            if let Some(val) = &assertion.value {
1183                let elixir_val = json_to_elixir(val);
1184                let _ = writeln!(out, "      assert {field_expr} < {elixir_val}");
1185            }
1186        }
1187        "greater_than_or_equal" => {
1188            if let Some(val) = &assertion.value {
1189                let elixir_val = json_to_elixir(val);
1190                let _ = writeln!(out, "      assert {field_expr} >= {elixir_val}");
1191            }
1192        }
1193        "less_than_or_equal" => {
1194            if let Some(val) = &assertion.value {
1195                let elixir_val = json_to_elixir(val);
1196                let _ = writeln!(out, "      assert {field_expr} <= {elixir_val}");
1197            }
1198        }
1199        "starts_with" => {
1200            if let Some(expected) = &assertion.value {
1201                let elixir_val = json_to_elixir(expected);
1202                let _ = writeln!(out, "      assert String.starts_with?({field_expr}, {elixir_val})");
1203            }
1204        }
1205        "ends_with" => {
1206            if let Some(expected) = &assertion.value {
1207                let elixir_val = json_to_elixir(expected);
1208                let _ = writeln!(out, "      assert String.ends_with?({field_expr}, {elixir_val})");
1209            }
1210        }
1211        "min_length" => {
1212            if let Some(val) = &assertion.value {
1213                if let Some(n) = val.as_u64() {
1214                    let _ = writeln!(out, "      assert String.length({field_expr}) >= {n}");
1215                }
1216            }
1217        }
1218        "max_length" => {
1219            if let Some(val) = &assertion.value {
1220                if let Some(n) = val.as_u64() {
1221                    let _ = writeln!(out, "      assert String.length({field_expr}) <= {n}");
1222                }
1223            }
1224        }
1225        "count_min" => {
1226            if let Some(val) = &assertion.value {
1227                if let Some(n) = val.as_u64() {
1228                    let _ = writeln!(out, "      assert length({field_expr}) >= {n}");
1229                }
1230            }
1231        }
1232        "count_equals" => {
1233            if let Some(val) = &assertion.value {
1234                if let Some(n) = val.as_u64() {
1235                    let _ = writeln!(out, "      assert length({field_expr}) == {n}");
1236                }
1237            }
1238        }
1239        "is_true" => {
1240            let _ = writeln!(out, "      assert {field_expr} == true");
1241        }
1242        "is_false" => {
1243            let _ = writeln!(out, "      assert {field_expr} == false");
1244        }
1245        "method_result" => {
1246            if let Some(method_name) = &assertion.method {
1247                let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1248                let check = assertion.check.as_deref().unwrap_or("is_true");
1249                match check {
1250                    "equals" => {
1251                        if let Some(val) = &assertion.value {
1252                            let elixir_val = json_to_elixir(val);
1253                            let _ = writeln!(out, "      assert {call_expr} == {elixir_val}");
1254                        }
1255                    }
1256                    "is_true" => {
1257                        let _ = writeln!(out, "      assert {call_expr} == true");
1258                    }
1259                    "is_false" => {
1260                        let _ = writeln!(out, "      assert {call_expr} == false");
1261                    }
1262                    "greater_than_or_equal" => {
1263                        if let Some(val) = &assertion.value {
1264                            let n = val.as_u64().unwrap_or(0);
1265                            let _ = writeln!(out, "      assert {call_expr} >= {n}");
1266                        }
1267                    }
1268                    "count_min" => {
1269                        if let Some(val) = &assertion.value {
1270                            let n = val.as_u64().unwrap_or(0);
1271                            let _ = writeln!(out, "      assert length({call_expr}) >= {n}");
1272                        }
1273                    }
1274                    "contains" => {
1275                        if let Some(val) = &assertion.value {
1276                            let elixir_val = json_to_elixir(val);
1277                            let _ = writeln!(out, "      assert String.contains?({call_expr}, {elixir_val})");
1278                        }
1279                    }
1280                    "is_error" => {
1281                        let _ = writeln!(out, "      assert_raise RuntimeError, fn -> {call_expr} end");
1282                    }
1283                    other_check => {
1284                        panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1285                    }
1286                }
1287            } else {
1288                panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1289            }
1290        }
1291        "matches_regex" => {
1292            if let Some(expected) = &assertion.value {
1293                let elixir_val = json_to_elixir(expected);
1294                let _ = writeln!(out, "      assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1295            }
1296        }
1297        "not_error" => {
1298            // Already handled — the call would fail if it returned {:error, _}.
1299        }
1300        "error" => {
1301            // Handled at the test level.
1302        }
1303        other => {
1304            panic!("Elixir e2e generator: unsupported assertion type: {other}");
1305        }
1306    }
1307}
1308
1309/// Build an Elixir call expression for a `method_result` assertion on a tree-sitter result.
1310/// Maps method names to the appropriate `module_path` function calls.
1311fn build_elixir_method_call(
1312    result_var: &str,
1313    method_name: &str,
1314    args: Option<&serde_json::Value>,
1315    module_path: &str,
1316) -> String {
1317    match method_name {
1318        "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1319        "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1320        "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1321        "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1322        "contains_node_type" => {
1323            let node_type = args
1324                .and_then(|a| a.get("node_type"))
1325                .and_then(|v| v.as_str())
1326                .unwrap_or("");
1327            format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1328        }
1329        "find_nodes_by_type" => {
1330            let node_type = args
1331                .and_then(|a| a.get("node_type"))
1332                .and_then(|v| v.as_str())
1333                .unwrap_or("");
1334            format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1335        }
1336        "run_query" => {
1337            let query_source = args
1338                .and_then(|a| a.get("query_source"))
1339                .and_then(|v| v.as_str())
1340                .unwrap_or("");
1341            let language = args
1342                .and_then(|a| a.get("language"))
1343                .and_then(|v| v.as_str())
1344                .unwrap_or("");
1345            format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1346        }
1347        _ => format!("{module_path}.{method_name}({result_var})"),
1348    }
1349}
1350
1351/// Convert a category name to an Elixir module-safe PascalCase name.
1352fn elixir_module_name(category: &str) -> String {
1353    use heck::ToUpperCamelCase;
1354    category.to_upper_camel_case()
1355}
1356
1357/// Convert a `serde_json::Value` to an Elixir literal string.
1358fn json_to_elixir(value: &serde_json::Value) -> String {
1359    match value {
1360        serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1361        serde_json::Value::Bool(true) => "true".to_string(),
1362        serde_json::Value::Bool(false) => "false".to_string(),
1363        serde_json::Value::Number(n) => {
1364            // Elixir requires floats to have a decimal point and does not accept
1365            // `e+N` exponent notation. Strip the `+` and ensure there is a decimal
1366            // point before any `e` exponent marker (e.g. `1e-10` → `1.0e-10`).
1367            let s = n.to_string().replace("e+", "e");
1368            if s.contains('e') && !s.contains('.') {
1369                // Insert `.0` before the `e` so Elixir treats this as a float.
1370                s.replacen('e', ".0e", 1)
1371            } else {
1372                s
1373            }
1374        }
1375        serde_json::Value::Null => "nil".to_string(),
1376        serde_json::Value::Array(arr) => {
1377            let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1378            format!("[{}]", items.join(", "))
1379        }
1380        serde_json::Value::Object(map) => {
1381            let entries: Vec<String> = map
1382                .iter()
1383                .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1384                .collect();
1385            format!("%{{{}}}", entries.join(", "))
1386        }
1387    }
1388}
1389
1390/// Build an Elixir visitor map and add setup line. Returns the visitor variable name.
1391fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1392    use std::fmt::Write as FmtWrite;
1393    let mut visitor_obj = String::new();
1394    let _ = writeln!(visitor_obj, "%{{");
1395    for (method_name, action) in &visitor_spec.callbacks {
1396        emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1397    }
1398    let _ = writeln!(visitor_obj, "    }}");
1399
1400    setup_lines.push(format!("visitor = {visitor_obj}"));
1401    "visitor".to_string()
1402}
1403
1404/// Emit an Elixir visitor method for a callback action.
1405fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1406    use std::fmt::Write as FmtWrite;
1407
1408    // Elixir uses atom keys and handle_ prefix
1409    let handle_method = format!("handle_{}", &method_name[6..]); // strip "visit_" prefix
1410    let params = match method_name {
1411        "visit_link" => "_ctx, _href, _text, _title",
1412        "visit_image" => "_ctx, _src, _alt, _title",
1413        "visit_heading" => "_ctx, _level, text, _id",
1414        "visit_code_block" => "_ctx, _lang, _code",
1415        "visit_code_inline"
1416        | "visit_strong"
1417        | "visit_emphasis"
1418        | "visit_strikethrough"
1419        | "visit_underline"
1420        | "visit_subscript"
1421        | "visit_superscript"
1422        | "visit_mark"
1423        | "visit_button"
1424        | "visit_summary"
1425        | "visit_figcaption"
1426        | "visit_definition_term"
1427        | "visit_definition_description" => "_ctx, _text",
1428        "visit_text" => "_ctx, _text",
1429        "visit_list_item" => "_ctx, _ordered, _marker, _text",
1430        "visit_blockquote" => "_ctx, _content, _depth",
1431        "visit_table_row" => "_ctx, _cells, _is_header",
1432        "visit_custom_element" => "_ctx, _tag_name, _html",
1433        "visit_form" => "_ctx, _action_url, _method",
1434        "visit_input" => "_ctx, _input_type, _name, _value",
1435        "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
1436        "visit_details" => "_ctx, _is_open",
1437        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "_ctx, _output",
1438        "visit_list_start" => "_ctx, _ordered",
1439        "visit_list_end" => "_ctx, _ordered, _output",
1440        _ => "_ctx",
1441    };
1442
1443    let _ = writeln!(out, "      :{handle_method} => fn({params}) ->");
1444    match action {
1445        CallbackAction::Skip => {
1446            let _ = writeln!(out, "        :skip");
1447        }
1448        CallbackAction::Continue => {
1449            let _ = writeln!(out, "        :continue");
1450        }
1451        CallbackAction::PreserveHtml => {
1452            let _ = writeln!(out, "        :preserve_html");
1453        }
1454        CallbackAction::Custom { output } => {
1455            let escaped = escape_elixir(output);
1456            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
1457        }
1458        CallbackAction::CustomTemplate { template } => {
1459            // For template, use string interpolation in Elixir (but simplified without arg binding)
1460            let escaped = escape_elixir(template);
1461            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
1462        }
1463    }
1464    let _ = writeln!(out, "      end,");
1465}