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::{
7    Assertion, CallbackAction, Fixture, FixtureGroup, HttpExpectedResponse, HttpFixture, HttpRequest,
8};
9use alef_core::backend::GeneratedFile;
10use alef_core::config::AlefConfig;
11use anyhow::Result;
12use heck::ToSnakeCase;
13use std::collections::HashMap;
14use std::fmt::Write as FmtWrite;
15use std::path::PathBuf;
16
17use super::E2eCodegen;
18
19/// Elixir e2e code generator.
20pub struct ElixirCodegen;
21
22impl E2eCodegen for ElixirCodegen {
23    fn generate(
24        &self,
25        groups: &[FixtureGroup],
26        e2e_config: &E2eConfig,
27        _alef_config: &AlefConfig,
28    ) -> Result<Vec<GeneratedFile>> {
29        let lang = self.language_name();
30        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
31
32        let mut files = Vec::new();
33
34        // Resolve call config with overrides.
35        let call = &e2e_config.call;
36        let overrides = call.overrides.get(lang);
37        let raw_module = overrides
38            .and_then(|o| o.module.as_ref())
39            .cloned()
40            .unwrap_or_else(|| call.module.clone());
41        // Convert module path to Elixir PascalCase if it looks like snake_case
42        // (e.g., "html_to_markdown" -> "HtmlToMarkdown").
43        // If the override already contains "." (e.g., "Elixir.HtmlToMarkdown"), use as-is.
44        let module_path = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase()) {
45            raw_module.clone()
46        } else {
47            elixir_module_name(&raw_module)
48        };
49        let base_function_name = overrides
50            .and_then(|o| o.function.as_ref())
51            .cloned()
52            .unwrap_or_else(|| call.function.clone());
53        // Elixir facade exports async variants with `_async` suffix when the call is async.
54        // Append the suffix only if not already present.
55        let function_name = if call.r#async && !base_function_name.ends_with("_async") {
56            format!("{base_function_name}_async")
57        } else {
58            base_function_name
59        };
60        let options_type = overrides.and_then(|o| o.options_type.clone());
61        let options_default_fn = overrides.and_then(|o| o.options_via.clone());
62        let empty_enum_fields = HashMap::new();
63        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
64        let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
65        let empty_atom_fields = std::collections::HashSet::new();
66        let handle_atom_list_fields = overrides
67            .map(|o| &o.handle_atom_list_fields)
68            .unwrap_or(&empty_atom_fields);
69        let result_var = &call.result_var;
70
71        // Resolve package config.
72        let elixir_pkg = e2e_config.resolve_package("elixir");
73        let pkg_path = elixir_pkg
74            .as_ref()
75            .and_then(|p| p.path.as_ref())
76            .cloned()
77            .unwrap_or_else(|| "../../packages/elixir".to_string());
78        // The dep atom must be a valid snake_case Elixir atom (e.g., :html_to_markdown),
79        // derived from the call module name, not the PascalCase module path.
80        let dep_atom = elixir_pkg
81            .as_ref()
82            .and_then(|p| p.name.as_ref())
83            .cloned()
84            .unwrap_or_else(|| raw_module.to_snake_case());
85        let dep_version = elixir_pkg
86            .as_ref()
87            .and_then(|p| p.version.as_ref())
88            .cloned()
89            .unwrap_or_else(|| "0.1.0".to_string());
90
91        // Check if any fixture in any group is an HTTP test.
92        let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
93
94        // Generate mix.exs.
95        files.push(GeneratedFile {
96            path: output_base.join("mix.exs"),
97            content: render_mix_exs(&dep_atom, &pkg_path, &dep_version, e2e_config.dep_mode, has_http_tests),
98            generated_header: false,
99        });
100
101        // Generate lib/e2e_elixir.ex — required so the mix project compiles.
102        files.push(GeneratedFile {
103            path: output_base.join("lib").join("e2e_elixir.ex"),
104            content: "defmodule E2eElixir do\n  @moduledoc false\nend\n".to_string(),
105            generated_header: false,
106        });
107
108        // Generate test_helper.exs.
109        files.push(GeneratedFile {
110            path: output_base.join("test").join("test_helper.exs"),
111            content: "ExUnit.start()\n".to_string(),
112            generated_header: false,
113        });
114
115        // Generate test files per category.
116        for group in groups {
117            let active: Vec<&Fixture> = group
118                .fixtures
119                .iter()
120                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
121                .collect();
122
123            if active.is_empty() {
124                continue;
125            }
126
127            let filename = format!("{}_test.exs", sanitize_filename(&group.category));
128            let field_resolver = FieldResolver::new(
129                &e2e_config.fields,
130                &e2e_config.fields_optional,
131                &e2e_config.result_fields,
132                &e2e_config.fields_array,
133            );
134            let content = render_test_file(
135                &group.category,
136                &active,
137                &module_path,
138                &function_name,
139                result_var,
140                &e2e_config.call.args,
141                &field_resolver,
142                options_type.as_deref(),
143                options_default_fn.as_deref(),
144                enum_fields,
145                handle_struct_type.as_deref(),
146                handle_atom_list_fields,
147            );
148            files.push(GeneratedFile {
149                path: output_base.join("test").join(filename),
150                content,
151                generated_header: true,
152            });
153        }
154
155        Ok(files)
156    }
157
158    fn language_name(&self) -> &'static str {
159        "elixir"
160    }
161}
162
163fn render_mix_exs(
164    dep_atom: &str,
165    pkg_path: &str,
166    dep_version: &str,
167    dep_mode: crate::config::DependencyMode,
168    has_http_tests: bool,
169) -> String {
170    let mut out = String::new();
171    let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
172    let _ = writeln!(out, "  use Mix.Project");
173    let _ = writeln!(out);
174    let _ = writeln!(out, "  def project do");
175    let _ = writeln!(out, "    [");
176    let _ = writeln!(out, "      app: :e2e_elixir,");
177    let _ = writeln!(out, "      version: \"0.1.0\",");
178    let _ = writeln!(out, "      elixir: \"~> 1.14\",");
179    let _ = writeln!(out, "      deps: deps()");
180    let _ = writeln!(out, "    ]");
181    let _ = writeln!(out, "  end");
182    let _ = writeln!(out);
183    let _ = writeln!(out, "  defp deps do");
184    let _ = writeln!(out, "    [");
185    // Use a bare atom for the dep name (e.g., :html_to_markdown), not a quoted atom.
186    let dep_line = match dep_mode {
187        crate::config::DependencyMode::Registry => {
188            format!("      {{:{dep_atom}, \"{dep_version}\"}}")
189        }
190        crate::config::DependencyMode::Local => {
191            format!("      {{:{dep_atom}, path: \"{pkg_path}\"}}")
192        }
193    };
194    let _ = writeln!(out, "{dep_line}");
195    if has_http_tests {
196        let _ = writeln!(out, "      {{:req, \"~> 0.5\"}}");
197        let _ = writeln!(out, "      {{:jason, \"~> 1.4\"}}");
198    }
199    let _ = writeln!(out, "    ]");
200    let _ = writeln!(out, "  end");
201    let _ = writeln!(out, "end");
202    out
203}
204
205#[allow(clippy::too_many_arguments)]
206fn render_test_file(
207    category: &str,
208    fixtures: &[&Fixture],
209    module_path: &str,
210    function_name: &str,
211    result_var: &str,
212    args: &[crate::config::ArgMapping],
213    field_resolver: &FieldResolver,
214    options_type: Option<&str>,
215    options_default_fn: Option<&str>,
216    enum_fields: &HashMap<String, String>,
217    handle_struct_type: Option<&str>,
218    handle_atom_list_fields: &std::collections::HashSet<String>,
219) -> String {
220    let mut out = String::new();
221    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
222    let _ = writeln!(out, "# E2e tests for category: {category}");
223    let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
224    let _ = writeln!(out, "  use ExUnit.Case, async: true");
225
226    // Add client helper when there are HTTP fixtures in this group.
227    let has_http = fixtures.iter().any(|f| f.is_http_test());
228    if has_http {
229        let _ = writeln!(out);
230        let _ = writeln!(out, "  defp base_url do");
231        let _ = writeln!(
232            out,
233            "    System.get_env(\"TEST_SERVER_URL\") || \"http://localhost:8080\""
234        );
235        let _ = writeln!(out, "  end");
236        let _ = writeln!(out);
237        let _ = writeln!(out, "  defp client do");
238        let _ = writeln!(out, "    Req.new(base_url: base_url())");
239        let _ = writeln!(out, "  end");
240    }
241
242    let _ = writeln!(out);
243
244    for (i, fixture) in fixtures.iter().enumerate() {
245        if let Some(http) = &fixture.http {
246            render_http_test_case(&mut out, fixture, http);
247        } else {
248            render_test_case(
249                &mut out,
250                fixture,
251                module_path,
252                function_name,
253                result_var,
254                args,
255                field_resolver,
256                options_type,
257                options_default_fn,
258                enum_fields,
259                handle_struct_type,
260                handle_atom_list_fields,
261            );
262        }
263        if i + 1 < fixtures.len() {
264            let _ = writeln!(out);
265        }
266    }
267
268    let _ = writeln!(out, "end");
269    out
270}
271
272// ---------------------------------------------------------------------------
273// HTTP test rendering
274// ---------------------------------------------------------------------------
275
276/// Render an ExUnit `describe` + `test` block for an HTTP server test fixture.
277fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
278    let test_name = sanitize_ident(&fixture.id);
279    let description = fixture.description.replace('"', "\\\"");
280    let method = http.request.method.to_uppercase();
281    let path = &http.request.path;
282
283    let _ = writeln!(out, "  describe \"{test_name}\" do");
284    let _ = writeln!(out, "    test \"{method} {path} - {description}\" do");
285
286    // Build request.
287    render_elixir_http_request(out, &http.request);
288
289    // Assert status.
290    let status = http.expected_response.status_code;
291    let _ = writeln!(out, "      assert response.status == {status}");
292
293    // Assert body.
294    render_elixir_body_assertions(out, &http.expected_response);
295
296    // Assert headers.
297    render_elixir_header_assertions(out, &http.expected_response);
298
299    let _ = writeln!(out, "    end");
300    let _ = writeln!(out, "  end");
301}
302
303/// Emit Req request lines inside an ExUnit test.
304fn render_elixir_http_request(out: &mut String, req: &HttpRequest) {
305    let method = req.method.to_lowercase();
306
307    let mut opts: Vec<String> = Vec::new();
308
309    if let Some(body) = &req.body {
310        let elixir_val = json_to_elixir(body);
311        opts.push(format!("json: {elixir_val}"));
312    }
313
314    if !req.headers.is_empty() {
315        let header_pairs: Vec<String> = req
316            .headers
317            .iter()
318            .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
319            .collect();
320        opts.push(format!("headers: [{}]", header_pairs.join(", ")));
321    }
322
323    if !req.cookies.is_empty() {
324        let cookie_str = req
325            .cookies
326            .iter()
327            .map(|(k, v)| format!("{}={}", k, v))
328            .collect::<Vec<_>>()
329            .join("; ");
330        opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
331    }
332
333    if !req.query_params.is_empty() {
334        let pairs: Vec<String> = req
335            .query_params
336            .iter()
337            .map(|(k, v)| {
338                let val_str = match v {
339                    serde_json::Value::String(s) => s.clone(),
340                    other => other.to_string(),
341                };
342                format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
343            })
344            .collect();
345        opts.push(format!("params: [{}]", pairs.join(", ")));
346    }
347
348    let path_lit = format!("\"{}\"", escape_elixir(&req.path));
349    if opts.is_empty() {
350        let _ = writeln!(out, "      {{:ok, response}} = Req.{method}(client(), url: {path_lit})");
351    } else {
352        let opts_str = opts.join(", ");
353        let _ = writeln!(
354            out,
355            "      {{:ok, response}} = Req.{method}(client(), url: {path_lit}, {opts_str})"
356        );
357    }
358}
359
360/// Emit body assertions for an HTTP expected response.
361fn render_elixir_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
362    if let Some(body) = &expected.body {
363        let elixir_val = json_to_elixir(body);
364        let _ = writeln!(out, "      assert Jason.decode!(response.body) == {elixir_val}");
365    }
366    if let Some(partial) = &expected.body_partial {
367        if let Some(obj) = partial.as_object() {
368            let _ = writeln!(out, "      decoded_body = Jason.decode!(response.body)");
369            for (key, val) in obj {
370                let key_lit = format!("\"{}\"", escape_elixir(key));
371                let elixir_val = json_to_elixir(val);
372                let _ = writeln!(out, "      assert decoded_body[{key_lit}] == {elixir_val}");
373            }
374        }
375    }
376    if let Some(errors) = &expected.validation_errors {
377        for err in errors {
378            let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
379            let _ = writeln!(
380                out,
381                "      assert String.contains?(Jason.encode!(response.body), {msg_lit})"
382            );
383        }
384    }
385}
386
387/// Emit header assertions for an HTTP expected response.
388///
389/// Special tokens:
390/// - `"<<present>>"` — assert the header key exists
391/// - `"<<absent>>"` — assert the header key is absent
392/// - `"<<uuid>>"` — assert the header value matches a UUID regex
393fn render_elixir_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
394    for (name, value) in &expected.headers {
395        let header_key = name.to_lowercase();
396        let key_lit = format!("\"{}\"", escape_elixir(&header_key));
397        // Req stores response headers as a list of {name, value} tuples.
398        let get_header_expr =
399            format!("Enum.find_value(response.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: v end)");
400        match value.as_str() {
401            "<<present>>" => {
402                let _ = writeln!(out, "      assert {get_header_expr} != nil");
403            }
404            "<<absent>>" => {
405                let _ = writeln!(out, "      assert {get_header_expr} == nil");
406            }
407            "<<uuid>>" => {
408                let _ = writeln!(
409                    out,
410                    "      header_val_{} = {get_header_expr}",
411                    sanitize_ident(&header_key)
412                );
413                let _ = writeln!(
414                    out,
415                    "      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_{}))",
416                    sanitize_ident(&header_key)
417                );
418            }
419            literal => {
420                let val_lit = format!("\"{}\"", escape_elixir(literal));
421                let _ = writeln!(out, "      assert {get_header_expr} == {val_lit}");
422            }
423        }
424    }
425}
426
427// ---------------------------------------------------------------------------
428// Function-call test rendering
429// ---------------------------------------------------------------------------
430
431#[allow(clippy::too_many_arguments)]
432fn render_test_case(
433    out: &mut String,
434    fixture: &Fixture,
435    module_path: &str,
436    function_name: &str,
437    result_var: &str,
438    args: &[crate::config::ArgMapping],
439    field_resolver: &FieldResolver,
440    options_type: Option<&str>,
441    options_default_fn: Option<&str>,
442    enum_fields: &HashMap<String, String>,
443    handle_struct_type: Option<&str>,
444    handle_atom_list_fields: &std::collections::HashSet<String>,
445) {
446    let test_name = sanitize_ident(&fixture.id);
447    let description = fixture.description.replace('"', "\\\"");
448
449    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
450
451    let (mut setup_lines, args_str) = build_args_and_setup(
452        &fixture.input,
453        args,
454        module_path,
455        options_type,
456        options_default_fn,
457        enum_fields,
458        &fixture.id,
459        handle_struct_type,
460        handle_atom_list_fields,
461    );
462
463    let _ = writeln!(out, "  describe \"{test_name}\" do");
464    let _ = writeln!(out, "    test \"{description}\" do");
465
466    for line in &setup_lines {
467        let _ = writeln!(out, "      {line}");
468    }
469
470    // Build visitor if present
471    let final_args = if let Some(visitor_spec) = &fixture.visitor {
472        let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
473        format!("{args_str}, {visitor_var}")
474    } else {
475        args_str
476    };
477
478    if expects_error {
479        let _ = writeln!(
480            out,
481            "      assert {{:error, _}} = {module_path}.{function_name}({final_args})"
482        );
483        let _ = writeln!(out, "    end");
484        let _ = writeln!(out, "  end");
485        return;
486    }
487
488    let _ = writeln!(
489        out,
490        "      {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
491    );
492
493    for assertion in &fixture.assertions {
494        render_assertion(out, assertion, result_var, field_resolver);
495    }
496
497    let _ = writeln!(out, "    end");
498    let _ = writeln!(out, "  end");
499}
500
501/// Build setup lines (e.g. handle creation) and the argument list for the function call.
502///
503/// Returns `(setup_lines, args_string)`.
504#[allow(clippy::too_many_arguments)]
505fn build_args_and_setup(
506    input: &serde_json::Value,
507    args: &[crate::config::ArgMapping],
508    module_path: &str,
509    options_type: Option<&str>,
510    options_default_fn: Option<&str>,
511    enum_fields: &HashMap<String, String>,
512    fixture_id: &str,
513    _handle_struct_type: Option<&str>,
514    _handle_atom_list_fields: &std::collections::HashSet<String>,
515) -> (Vec<String>, String) {
516    if args.is_empty() {
517        return (Vec::new(), json_to_elixir(input));
518    }
519
520    let mut setup_lines: Vec<String> = Vec::new();
521    let mut parts: Vec<String> = Vec::new();
522
523    for arg in args {
524        if arg.arg_type == "mock_url" {
525            setup_lines.push(format!(
526                "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
527                arg.name,
528            ));
529            parts.push(arg.name.clone());
530            continue;
531        }
532
533        if arg.arg_type == "handle" {
534            // Generate a create_{name} call using {:ok, name} = ... pattern.
535            // The NIF now accepts config as an optional JSON string (not a NifStruct/NifMap)
536            // so that partial maps work: serde_json::from_str respects #[serde(default)].
537            let constructor_name = format!("create_{}", arg.name.to_snake_case());
538            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
539            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
540            let name = &arg.name;
541            if config_value.is_null()
542                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
543            {
544                setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
545            } else {
546                // Serialize the config map to a JSON string with Jason so that Rust can
547                // deserialize it with serde_json and apply field defaults for missing keys.
548                let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
549                let escaped = escape_elixir(&json_str);
550                setup_lines.push(format!("{name}_config = \"{escaped}\""));
551                setup_lines.push(format!(
552                    "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
553                ));
554            }
555            parts.push(arg.name.clone());
556            continue;
557        }
558
559        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
560        let val = input.get(field);
561        match val {
562            None | Some(serde_json::Value::Null) if arg.optional => {
563                // Optional arg with no fixture value: skip entirely.
564                continue;
565            }
566            None | Some(serde_json::Value::Null) => {
567                // Required arg with no fixture value: pass a language-appropriate default.
568                let default_val = match arg.arg_type.as_str() {
569                    "string" => "\"\"".to_string(),
570                    "int" | "integer" => "0".to_string(),
571                    "float" | "number" => "0.0".to_string(),
572                    "bool" | "boolean" => "false".to_string(),
573                    _ => "nil".to_string(),
574                };
575                parts.push(default_val);
576            }
577            Some(v) => {
578                // For json_object args with options_type, build a proper struct.
579                if arg.arg_type == "json_object" && !v.is_null() {
580                    if let (Some(_opts_type), Some(options_fn), Some(obj)) =
581                        (options_type, options_default_fn, v.as_object())
582                    {
583                        // Add setup line to initialize options from default function.
584                        let options_var = "options";
585                        setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
586
587                        // For each field in the options object, add a struct update line.
588                        for (k, vv) in obj.iter() {
589                            let snake_key = k.to_snake_case();
590                            let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
591                                if let Some(s) = vv.as_str() {
592                                    let snake_val = s.to_snake_case();
593                                    // Use atom for enum values, not string
594                                    format!(":{snake_val}")
595                                } else {
596                                    json_to_elixir(vv)
597                                }
598                            } else {
599                                json_to_elixir(vv)
600                            };
601                            setup_lines.push(format!(
602                                "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
603                            ));
604                        }
605
606                        // Push the variable name as the argument.
607                        parts.push(options_var.to_string());
608                        continue;
609                    }
610                }
611                parts.push(json_to_elixir(v));
612            }
613        }
614    }
615
616    (setup_lines, parts.join(", "))
617}
618
619/// Returns true if the field expression is a numeric/integer expression
620/// (e.g., a `length(...)` call) rather than a string.
621fn is_numeric_expr(field_expr: &str) -> bool {
622    field_expr.starts_with("length(")
623}
624
625fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
626    // Skip assertions on fields that don't exist on the result type.
627    if let Some(f) = &assertion.field {
628        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
629            let _ = writeln!(out, "      # skipped: field '{f}' not available on result type");
630            return;
631        }
632    }
633
634    let field_expr = match &assertion.field {
635        Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
636        _ => result_var.to_string(),
637    };
638
639    // Only wrap in String.trim/0 when the expression is actually a string.
640    // Numeric expressions (e.g., length(...)) must not be wrapped.
641    let is_numeric = is_numeric_expr(&field_expr);
642    let trimmed_field_expr = if is_numeric {
643        field_expr.clone()
644    } else {
645        format!("String.trim({field_expr})")
646    };
647
648    match assertion.assertion_type.as_str() {
649        "equals" => {
650            if let Some(expected) = &assertion.value {
651                let elixir_val = json_to_elixir(expected);
652                // Apply String.trim only for string comparisons, not numeric ones.
653                let is_string_expected = expected.is_string();
654                if is_string_expected && !is_numeric {
655                    let _ = writeln!(out, "      assert {trimmed_field_expr} == {elixir_val}");
656                } else {
657                    let _ = writeln!(out, "      assert {field_expr} == {elixir_val}");
658                }
659            }
660        }
661        "contains" => {
662            if let Some(expected) = &assertion.value {
663                let elixir_val = json_to_elixir(expected);
664                // Use to_string() to handle atoms (enums) as well as strings
665                let _ = writeln!(
666                    out,
667                    "      assert String.contains?(to_string({field_expr}), {elixir_val})"
668                );
669            }
670        }
671        "contains_all" => {
672            if let Some(values) = &assertion.values {
673                for val in values {
674                    let elixir_val = json_to_elixir(val);
675                    let _ = writeln!(
676                        out,
677                        "      assert String.contains?(to_string({field_expr}), {elixir_val})"
678                    );
679                }
680            }
681        }
682        "not_contains" => {
683            if let Some(expected) = &assertion.value {
684                let elixir_val = json_to_elixir(expected);
685                let _ = writeln!(
686                    out,
687                    "      refute String.contains?(to_string({field_expr}), {elixir_val})"
688                );
689            }
690        }
691        "not_empty" => {
692            let _ = writeln!(out, "      assert {field_expr} != \"\"");
693        }
694        "is_empty" => {
695            if is_numeric {
696                // length(...) == 0
697                let _ = writeln!(out, "      assert {field_expr} == 0");
698            } else {
699                // Handle nil (None) as empty
700                let _ = writeln!(out, "      assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
701            }
702        }
703        "contains_any" => {
704            if let Some(values) = &assertion.values {
705                let items: Vec<String> = values.iter().map(json_to_elixir).collect();
706                let list_str = items.join(", ");
707                let _ = writeln!(
708                    out,
709                    "      assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
710                );
711            }
712        }
713        "greater_than" => {
714            if let Some(val) = &assertion.value {
715                let elixir_val = json_to_elixir(val);
716                let _ = writeln!(out, "      assert {field_expr} > {elixir_val}");
717            }
718        }
719        "less_than" => {
720            if let Some(val) = &assertion.value {
721                let elixir_val = json_to_elixir(val);
722                let _ = writeln!(out, "      assert {field_expr} < {elixir_val}");
723            }
724        }
725        "greater_than_or_equal" => {
726            if let Some(val) = &assertion.value {
727                let elixir_val = json_to_elixir(val);
728                let _ = writeln!(out, "      assert {field_expr} >= {elixir_val}");
729            }
730        }
731        "less_than_or_equal" => {
732            if let Some(val) = &assertion.value {
733                let elixir_val = json_to_elixir(val);
734                let _ = writeln!(out, "      assert {field_expr} <= {elixir_val}");
735            }
736        }
737        "starts_with" => {
738            if let Some(expected) = &assertion.value {
739                let elixir_val = json_to_elixir(expected);
740                let _ = writeln!(out, "      assert String.starts_with?({field_expr}, {elixir_val})");
741            }
742        }
743        "ends_with" => {
744            if let Some(expected) = &assertion.value {
745                let elixir_val = json_to_elixir(expected);
746                let _ = writeln!(out, "      assert String.ends_with?({field_expr}, {elixir_val})");
747            }
748        }
749        "min_length" => {
750            if let Some(val) = &assertion.value {
751                if let Some(n) = val.as_u64() {
752                    let _ = writeln!(out, "      assert String.length({field_expr}) >= {n}");
753                }
754            }
755        }
756        "max_length" => {
757            if let Some(val) = &assertion.value {
758                if let Some(n) = val.as_u64() {
759                    let _ = writeln!(out, "      assert String.length({field_expr}) <= {n}");
760                }
761            }
762        }
763        "count_min" => {
764            if let Some(val) = &assertion.value {
765                if let Some(n) = val.as_u64() {
766                    let _ = writeln!(out, "      assert length({field_expr}) >= {n}");
767                }
768            }
769        }
770        "count_equals" => {
771            if let Some(val) = &assertion.value {
772                if let Some(n) = val.as_u64() {
773                    let _ = writeln!(out, "      assert length({field_expr}) == {n}");
774                }
775            }
776        }
777        "is_true" => {
778            let _ = writeln!(out, "      assert {field_expr} == true");
779        }
780        "not_error" => {
781            // Already handled — the call would fail if it returned {:error, _}.
782        }
783        "error" => {
784            // Handled at the test level.
785        }
786        other => {
787            let _ = writeln!(out, "      # TODO: unsupported assertion type: {other}");
788        }
789    }
790}
791
792/// Convert a category name to an Elixir module-safe PascalCase name.
793fn elixir_module_name(category: &str) -> String {
794    use heck::ToUpperCamelCase;
795    category.to_upper_camel_case()
796}
797
798/// Convert a `serde_json::Value` to an Elixir literal string.
799fn json_to_elixir(value: &serde_json::Value) -> String {
800    match value {
801        serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
802        serde_json::Value::Bool(true) => "true".to_string(),
803        serde_json::Value::Bool(false) => "false".to_string(),
804        serde_json::Value::Number(n) => n.to_string(),
805        serde_json::Value::Null => "nil".to_string(),
806        serde_json::Value::Array(arr) => {
807            let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
808            format!("[{}]", items.join(", "))
809        }
810        serde_json::Value::Object(map) => {
811            let entries: Vec<String> = map
812                .iter()
813                .map(|(k, v)| format!("\"{}\" => {}", k.to_snake_case(), json_to_elixir(v)))
814                .collect();
815            format!("%{{{}}}", entries.join(", "))
816        }
817    }
818}
819
820/// Build an Elixir visitor map and add setup line. Returns the visitor variable name.
821fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
822    use std::fmt::Write as FmtWrite;
823    let mut visitor_obj = String::new();
824    let _ = writeln!(visitor_obj, "%{{");
825    for (method_name, action) in &visitor_spec.callbacks {
826        emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
827    }
828    let _ = writeln!(visitor_obj, "    }}");
829
830    setup_lines.push(format!("visitor = {visitor_obj}"));
831    "visitor".to_string()
832}
833
834/// Emit an Elixir visitor method for a callback action.
835fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
836    use std::fmt::Write as FmtWrite;
837
838    // Elixir uses atom keys and handle_ prefix
839    let handle_method = format!("handle_{}", &method_name[6..]); // strip "visit_" prefix
840    let params = match method_name {
841        "visit_link" => "_ctx, _href, _text, _title",
842        "visit_image" => "_ctx, _src, _alt, _title",
843        "visit_heading" => "_ctx, _level, text, _id",
844        "visit_code_block" => "_ctx, _lang, _code",
845        "visit_code_inline"
846        | "visit_strong"
847        | "visit_emphasis"
848        | "visit_strikethrough"
849        | "visit_underline"
850        | "visit_subscript"
851        | "visit_superscript"
852        | "visit_mark"
853        | "visit_button"
854        | "visit_summary"
855        | "visit_figcaption"
856        | "visit_definition_term"
857        | "visit_definition_description" => "_ctx, _text",
858        "visit_text" => "_ctx, _text",
859        "visit_list_item" => "_ctx, _ordered, _marker, _text",
860        "visit_blockquote" => "_ctx, _content, _depth",
861        "visit_table_row" => "_ctx, _cells, _is_header",
862        "visit_custom_element" => "_ctx, _tag_name, _html",
863        "visit_form" => "_ctx, _action_url, _method",
864        "visit_input" => "_ctx, _input_type, _name, _value",
865        "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
866        "visit_details" => "_ctx, _is_open",
867        _ => "_ctx",
868    };
869
870    let _ = writeln!(out, "      :{handle_method} => fn({params}) ->");
871    match action {
872        CallbackAction::Skip => {
873            let _ = writeln!(out, "        :skip");
874        }
875        CallbackAction::Continue => {
876            let _ = writeln!(out, "        :continue");
877        }
878        CallbackAction::PreserveHtml => {
879            let _ = writeln!(out, "        :preserve_html");
880        }
881        CallbackAction::Custom { output } => {
882            let escaped = escape_elixir(output);
883            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
884        }
885        CallbackAction::CustomTemplate { template } => {
886            // For template, use string interpolation in Elixir (but simplified without arg binding)
887            let escaped = escape_elixir(template);
888            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
889        }
890    }
891    let _ = writeln!(out, "      end,");
892}