1use 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
19pub 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 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 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 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 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 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 let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
93
94 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 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 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 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 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 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
272fn 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 render_elixir_http_request(out, &http.request);
288
289 let status = http.expected_response.status_code;
291 let _ = writeln!(out, " assert response.status == {status}");
292
293 render_elixir_body_assertions(out, &http.expected_response);
295
296 render_elixir_header_assertions(out, &http.expected_response);
298
299 let _ = writeln!(out, " end");
300 let _ = writeln!(out, " end");
301}
302
303fn 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
360fn 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
387fn 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 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#[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 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#[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 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 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 continue;
565 }
566 None | Some(serde_json::Value::Null) => {
567 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 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 let options_var = "options";
585 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
586
587 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 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 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
619fn 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 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 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 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 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 let _ = writeln!(out, " assert {field_expr} == 0");
698 } else {
699 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 }
783 "error" => {
784 }
786 other => {
787 let _ = writeln!(out, " # TODO: unsupported assertion type: {other}");
788 }
789 }
790}
791
792fn elixir_module_name(category: &str) -> String {
794 use heck::ToUpperCamelCase;
795 category.to_upper_camel_case()
796}
797
798fn 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
820fn 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
834fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
836 use std::fmt::Write as FmtWrite;
837
838 let handle_method = format!("handle_{}", &method_name[6..]); 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 let escaped = escape_elixir(template);
888 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
889 }
890 }
891 let _ = writeln!(out, " end,");
892}