1use crate::codegen::resolve_field;
7use crate::config::E2eConfig;
8use crate::escape::{ruby_string_literal, ruby_template_to_interpolation, sanitize_filename, sanitize_ident};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, ValidationErrorExpectation};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::ResolvedCrateConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions as tv;
15use anyhow::Result;
16use heck::ToSnakeCase;
17use std::collections::HashMap;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22use super::client;
23
24pub struct RubyCodegen;
26
27impl E2eCodegen for RubyCodegen {
28 fn generate(
29 &self,
30 groups: &[FixtureGroup],
31 e2e_config: &E2eConfig,
32 config: &ResolvedCrateConfig,
33 ) -> Result<Vec<GeneratedFile>> {
34 let lang = self.language_name();
35 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37 let mut files = Vec::new();
38
39 let call = &e2e_config.call;
41 let overrides = call.overrides.get(lang);
42 let module_path = overrides
43 .and_then(|o| o.module.as_ref())
44 .cloned()
45 .unwrap_or_else(|| call.module.clone());
46 let class_name = overrides.and_then(|o| o.class.as_ref()).cloned();
47 let options_type = overrides.and_then(|o| o.options_type.clone());
48 let empty_enum_fields = HashMap::new();
49 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
50 let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
51
52 let ruby_pkg = e2e_config.resolve_package("ruby");
54 let gem_name = ruby_pkg
55 .as_ref()
56 .and_then(|p| p.name.as_ref())
57 .cloned()
58 .unwrap_or_else(|| config.name.replace('-', "_"));
59 let gem_path = ruby_pkg
60 .as_ref()
61 .and_then(|p| p.path.as_ref())
62 .cloned()
63 .unwrap_or_else(|| "../../packages/ruby".to_string());
64 let gem_version = ruby_pkg
65 .as_ref()
66 .and_then(|p| p.version.as_ref())
67 .cloned()
68 .unwrap_or_else(|| "0.1.0".to_string());
69
70 files.push(GeneratedFile {
72 path: output_base.join("Gemfile"),
73 content: render_gemfile(&gem_name, &gem_path, &gem_version, e2e_config.dep_mode),
74 generated_header: false,
75 });
76
77 files.push(GeneratedFile {
79 path: output_base.join(".rubocop.yaml"),
80 content: render_rubocop_yaml(),
81 generated_header: false,
82 });
83
84 let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
86
87 let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
89 let cc = e2e_config.resolve_call(f.call.as_deref());
90 cc.args
91 .iter()
92 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
93 });
94
95 if has_file_fixtures || has_http_fixtures {
97 files.push(GeneratedFile {
98 path: output_base.join("spec").join("spec_helper.rb"),
99 content: render_spec_helper(has_file_fixtures, has_http_fixtures),
100 generated_header: true,
101 });
102 }
103
104 let spec_base = output_base.join("spec");
106
107 for group in groups {
108 let active: Vec<&Fixture> = group
109 .fixtures
110 .iter()
111 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
112 .collect();
113
114 if active.is_empty() {
115 continue;
116 }
117
118 let field_resolver_pre = FieldResolver::new(
119 &e2e_config.fields,
120 &e2e_config.fields_optional,
121 &e2e_config.result_fields,
122 &e2e_config.fields_array,
123 &std::collections::HashSet::new(),
124 );
125 let has_any_output = active.iter().any(|f| {
127 if f.is_http_test() {
129 return true;
130 }
131 let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
132 let has_not_error = f.assertions.iter().any(|a| a.assertion_type == "not_error");
133 expects_error || has_not_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
134 });
135 if !has_any_output {
136 continue;
137 }
138
139 let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
140 let field_resolver = FieldResolver::new(
141 &e2e_config.fields,
142 &e2e_config.fields_optional,
143 &e2e_config.result_fields,
144 &e2e_config.fields_array,
145 &std::collections::HashSet::new(),
146 );
147 let content = render_spec_file(
148 &group.category,
149 &active,
150 &module_path,
151 class_name.as_deref(),
152 &gem_name,
153 &field_resolver,
154 options_type.as_deref(),
155 enum_fields,
156 result_is_simple,
157 e2e_config,
158 has_file_fixtures || has_http_fixtures,
159 );
160 files.push(GeneratedFile {
161 path: spec_base.join(filename),
162 content,
163 generated_header: true,
164 });
165 }
166
167 Ok(files)
168 }
169
170 fn language_name(&self) -> &'static str {
171 "ruby"
172 }
173}
174
175fn render_gemfile(
180 gem_name: &str,
181 gem_path: &str,
182 gem_version: &str,
183 dep_mode: crate::config::DependencyMode,
184) -> String {
185 let gem_line = match dep_mode {
186 crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
187 crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
188 };
189 format!(
190 "# frozen_string_literal: true\n\
191 \n\
192 source 'https://rubygems.org'\n\
193 \n\
194 {gem_line}\n\
195 gem 'rspec', '{rspec}'\n\
196 gem 'rubocop', '{rubocop}'\n\
197 gem 'rubocop-rspec', '{rubocop_rspec}'\n\
198 gem 'faraday', '{faraday}'\n",
199 rspec = tv::gem::RSPEC_E2E,
200 rubocop = tv::gem::RUBOCOP_E2E,
201 rubocop_rspec = tv::gem::RUBOCOP_RSPEC_E2E,
202 faraday = tv::gem::FARADAY,
203 )
204}
205
206fn render_spec_helper(has_file_fixtures: bool, has_http_fixtures: bool) -> String {
207 let header = hash::header(CommentStyle::Hash);
208 let mut out = header;
209 out.push_str("# frozen_string_literal: true\n");
210
211 if has_file_fixtures {
212 out.push_str(
213 r#"
214# Change to the test_documents directory so that fixture file paths like
215# "pdf/fake_memo.pdf" resolve correctly when running rspec from e2e/ruby/.
216# spec_helper.rb lives in e2e/ruby/spec/; test_documents lives at the
217# repository root, three directories up: spec/ -> e2e/ruby/ -> e2e/ -> root.
218_test_documents = File.expand_path('../../../test_documents', __dir__)
219Dir.chdir(_test_documents) if Dir.exist?(_test_documents)
220"#,
221 );
222 }
223
224 if has_http_fixtures {
225 out.push_str(
226 r#"
227require 'open3'
228
229# Spawn the mock-server binary and set MOCK_SERVER_URL for all tests.
230RSpec.configure do |config|
231 config.before(:suite) do
232 bin = File.expand_path('../../rust/target/release/mock-server', __dir__)
233 fixtures_dir = File.expand_path('../../../fixtures', __dir__)
234 unless File.exist?(bin)
235 warn "mock-server binary not found at #{bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
236 end
237 stdin, stdout, _stderr, _wait = Open3.popen3(bin, fixtures_dir)
238 url = stdout.readline.strip.split('=', 2).last
239 ENV['MOCK_SERVER_URL'] = url
240 # Drain stdout in background.
241 Thread.new { stdout.read }
242 # Store stdin so we can close it on teardown.
243 @_mock_server_stdin = stdin
244 end
245
246 config.after(:suite) do
247 @_mock_server_stdin&.close
248 end
249end
250"#,
251 );
252 }
253
254 out
255}
256
257fn render_rubocop_yaml() -> String {
258 r#"# Generated by alef e2e — do not edit.
259AllCops:
260 NewCops: enable
261 TargetRubyVersion: 3.2
262 SuggestExtensions: false
263
264plugins:
265 - rubocop-rspec
266
267# --- Justified suppressions for generated test code ---
268
269# Generated tests are verbose by nature (setup + multiple assertions).
270Metrics/BlockLength:
271 Enabled: false
272Metrics/MethodLength:
273 Enabled: false
274Layout/LineLength:
275 Enabled: false
276
277# Generated tests use multiple assertions per example for thorough verification.
278RSpec/MultipleExpectations:
279 Enabled: false
280RSpec/ExampleLength:
281 Enabled: false
282
283# Generated tests describe categories as strings, not classes.
284RSpec/DescribeClass:
285 Enabled: false
286
287# Fixture-driven tests may produce identical assertion bodies for different inputs.
288RSpec/RepeatedExample:
289 Enabled: false
290
291# Error-handling tests use bare raise_error (exception type not known at generation time).
292RSpec/UnspecifiedException:
293 Enabled: false
294"#
295 .to_string()
296}
297
298#[allow(clippy::too_many_arguments)]
299fn render_spec_file(
300 category: &str,
301 fixtures: &[&Fixture],
302 module_path: &str,
303 class_name: Option<&str>,
304 gem_name: &str,
305 field_resolver: &FieldResolver,
306 options_type: Option<&str>,
307 enum_fields: &HashMap<String, String>,
308 result_is_simple: bool,
309 e2e_config: &E2eConfig,
310 needs_spec_helper: bool,
311) -> String {
312 let client_factory = e2e_config
314 .call
315 .overrides
316 .get("ruby")
317 .and_then(|o| o.client_factory.as_deref());
318
319 let mut out = String::new();
320 out.push_str(&hash::header(CommentStyle::Hash));
321 let _ = writeln!(out, "# frozen_string_literal: true");
322 let _ = writeln!(out);
323
324 let require_name = if module_path.is_empty() { gem_name } else { module_path };
326 let _ = writeln!(out, "require '{}'", require_name.replace('-', "_"));
327 let _ = writeln!(out, "require 'json'");
328
329 let has_http = fixtures.iter().any(|f| f.is_http_test());
330 if needs_spec_helper || has_http {
331 let _ = writeln!(out, "require_relative 'spec_helper'");
333 }
334 let _ = writeln!(out);
335
336 let call_receiver = class_name
338 .map(|s| s.to_string())
339 .unwrap_or_else(|| ruby_module_name(module_path));
340
341 let _ = writeln!(out, "RSpec.describe '{}' do", category);
342
343 let has_array_contains = fixtures.iter().any(|fixture| {
346 fixture.assertions.iter().any(|a| {
347 matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
348 && a.field
349 .as_deref()
350 .is_some_and(|f| !f.is_empty() && field_resolver.is_array(field_resolver.resolve(f)))
351 })
352 });
353 if has_array_contains {
354 let _ = writeln!(out);
355 let _ = writeln!(out, " def alef_e2e_item_texts(item)");
356 let _ = writeln!(
357 out,
358 " [:kind, :name, :signature, :path, :alias, :text, :source].filter_map do |attr|"
359 );
360 let _ = writeln!(out, " item.respond_to?(attr) ? item.send(attr).to_s : nil");
361 let _ = writeln!(out, " end");
362 let _ = writeln!(out, " end");
363 }
364
365 if has_http {
367 let _ = writeln!(
368 out,
369 " let(:mock_server_url) {{ ENV.fetch('MOCK_SERVER_URL', 'http://localhost:8080') }}"
370 );
371 let _ = writeln!(out);
372 }
373
374 let mut first = true;
375 for fixture in fixtures {
376 if !first {
377 let _ = writeln!(out);
378 }
379 first = false;
380
381 if fixture.http.is_some() {
382 render_http_example(&mut out, fixture);
383 } else {
384 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
388 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
389 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
390 if !expects_error && !has_usable {
391 let test_name = sanitize_ident(&fixture.id);
392 let description = fixture.description.replace('\'', "\\'");
393 if has_not_error {
394 let fixture_call = e2e_config.resolve_call(fixture.call.as_deref());
395 let fixture_call_overrides = fixture_call.overrides.get("ruby");
396 let fixture_function_name = fixture_call_overrides
397 .and_then(|o| o.function.as_ref())
398 .cloned()
399 .unwrap_or_else(|| fixture_call.function.clone());
400 let fixture_args = &fixture_call.args;
401 let fixture_options_type = fixture_call_overrides
402 .and_then(|o| o.options_type.as_deref())
403 .or(options_type);
404 let (setup_lines, args_str) = build_args_and_setup(
405 &fixture.input,
406 fixture_args,
407 &call_receiver,
408 fixture_options_type,
409 enum_fields,
410 result_is_simple,
411 &fixture.id,
412 );
413 let call_expr = format!("{call_receiver}.{fixture_function_name}({args_str})");
414 let _ = writeln!(out, " it '{test_name}: {description}' do");
415 for line in &setup_lines {
416 let _ = writeln!(out, " {line}");
417 }
418 let _ = writeln!(out, " expect {{ {call_expr} }}.not_to raise_error");
419 let _ = writeln!(out, " end");
420 } else {
421 let _ = writeln!(out, " it '{test_name}: {description}' do");
422 let _ = writeln!(out, " skip 'Non-HTTP fixture cannot be tested via Net::HTTP'");
423 let _ = writeln!(out, " end");
424 }
425 } else {
426 let fixture_call = e2e_config.resolve_call(fixture.call.as_deref());
428 let fixture_call_overrides = fixture_call.overrides.get("ruby");
429 let fixture_function_name = fixture_call_overrides
430 .and_then(|o| o.function.as_ref())
431 .cloned()
432 .unwrap_or_else(|| fixture_call.function.clone());
433 let fixture_result_var = &fixture_call.result_var;
434 let fixture_args = &fixture_call.args;
435 let fixture_client_factory = fixture_call_overrides
437 .and_then(|o| o.client_factory.as_deref())
438 .or(client_factory);
439 let fixture_options_type = fixture_call_overrides
441 .and_then(|o| o.options_type.as_deref())
442 .or(options_type);
443 render_example(
444 &mut out,
445 fixture,
446 &fixture_function_name,
447 &call_receiver,
448 fixture_result_var,
449 fixture_args,
450 field_resolver,
451 fixture_options_type,
452 enum_fields,
453 result_is_simple,
454 e2e_config,
455 fixture_client_factory,
456 );
457 }
458 }
459 }
460
461 let _ = writeln!(out, "end");
462 out
463}
464
465fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
468 fixture.assertions.iter().any(|a| {
469 if a.assertion_type == "not_error" || a.assertion_type == "error" {
471 return false;
472 }
473 if let Some(f) = &a.field {
475 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
476 return false;
477 }
478 if result_is_simple {
480 let f_lower = f.to_lowercase();
481 if !f.is_empty()
482 && f_lower != "content"
483 && (f_lower.starts_with("metadata")
484 || f_lower.starts_with("document")
485 || f_lower.starts_with("structure"))
486 {
487 return false;
488 }
489 }
490 }
491 true
492 })
493}
494
495struct RubyTestClientRenderer;
503
504impl client::TestClientRenderer for RubyTestClientRenderer {
505 fn language_name(&self) -> &'static str {
506 "ruby"
507 }
508
509 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
515 let escaped_description = description.replace('\'', "\\'");
516 let _ = writeln!(out, " describe '{fn_name}' do");
517 if let Some(reason) = skip_reason {
518 let _ = writeln!(out, " it '{escaped_description}' do");
519 let _ = writeln!(out, " skip '{reason}'");
520 } else {
523 let _ = writeln!(out, " it '{escaped_description}' do");
524 }
525 }
526
527 fn render_test_close(&self, out: &mut String) {
529 let _ = writeln!(out, " end");
530 let _ = writeln!(out, " end");
531 }
532
533 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
535 let method = ctx.method.to_uppercase();
536 let method_class = http_method_class(&method);
537 let _ = writeln!(out, " require 'net/http'");
538 let _ = writeln!(out, " require 'uri'");
539 let _ = writeln!(out, " require 'json'");
540 let _ = writeln!(out, " _uri = URI.parse(\"#{{mock_server_url}}{}\")", ctx.path);
541 let _ = writeln!(out, " _http = Net::HTTP.new(_uri.host, _uri.port)");
542 let _ = writeln!(out, " _http.use_ssl = _uri.scheme == 'https'");
543 let _ = writeln!(out, " _req = Net::HTTP::{method_class}.new(_uri.request_uri)");
544
545 let has_body = ctx
546 .body
547 .is_some_and(|b| !matches!(b, serde_json::Value::String(s) if s.is_empty()));
548 if has_body {
549 let ruby_body = json_to_ruby(ctx.body.unwrap());
550 let _ = writeln!(out, " _req.body = {ruby_body}.to_json");
551 let _ = writeln!(out, " _req['Content-Type'] = 'application/json'");
552 }
553
554 for (k, v) in ctx.headers {
555 if has_body && k.to_lowercase() == "content-type" {
557 continue;
558 }
559 let rk = ruby_string_literal(k);
560 let rv = ruby_string_literal(v);
561 let _ = writeln!(out, " _req[{rk}] = {rv}");
562 }
563
564 let _ = writeln!(out, " {} = _http.request(_req)", ctx.response_var);
565 }
566
567 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
572 let _ = writeln!(out, " expect({response_var}.code.to_i).to eq({status})");
573 }
574
575 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
579 let header_key = name.to_lowercase();
580 let header_expr = format!("{response_var}[{}]", ruby_string_literal(&header_key));
581 match expected {
582 "<<present>>" => {
583 let _ = writeln!(out, " expect({header_expr}).not_to be_nil");
584 }
585 "<<absent>>" => {
586 let _ = writeln!(out, " expect({header_expr}).to be_nil");
587 }
588 "<<uuid>>" => {
589 let _ = writeln!(
590 out,
591 " expect({header_expr}).to match(/\\A[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}\\z/i)"
592 );
593 }
594 literal => {
595 let ruby_val = ruby_string_literal(literal);
596 let _ = writeln!(out, " expect({header_expr}).to eq({ruby_val})");
597 }
598 }
599 }
600
601 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
606 match expected {
607 serde_json::Value::String(s) => {
608 let ruby_val = ruby_string_literal(s);
609 let _ = writeln!(out, " expect({response_var}.body).to eq({ruby_val})");
610 }
611 _ => {
612 let ruby_val = json_to_ruby(expected);
613 let _ = writeln!(
614 out,
615 " _body = {response_var}.body && !{response_var}.body.empty? ? JSON.parse({response_var}.body) : nil"
616 );
617 let _ = writeln!(out, " expect(_body).to eq({ruby_val})");
618 }
619 }
620 }
621
622 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
624 if let Some(obj) = expected.as_object() {
625 let _ = writeln!(out, " _body = JSON.parse({response_var}.body)");
626 for (key, val) in obj {
627 let ruby_key = ruby_string_literal(key);
628 let ruby_val = json_to_ruby(val);
629 let _ = writeln!(out, " expect(_body[{ruby_key}]).to eq({ruby_val})");
630 }
631 }
632 }
633
634 fn render_assert_validation_errors(
637 &self,
638 out: &mut String,
639 response_var: &str,
640 errors: &[ValidationErrorExpectation],
641 ) {
642 for err in errors {
643 let msg_lit = ruby_string_literal(&err.msg);
644 let _ = writeln!(out, " _body = JSON.parse({response_var}.body)");
645 let _ = writeln!(out, " _errors = _body['errors'] || []");
646 let _ = writeln!(
647 out,
648 " expect(_errors.map {{ |e| e['msg'] }}).to include({msg_lit})"
649 );
650 }
651 }
652}
653
654fn render_http_example(out: &mut String, fixture: &Fixture) {
660 if fixture
664 .http
665 .as_ref()
666 .is_some_and(|h| h.expected_response.status_code == 101)
667 {
668 if let Some(http) = fixture.http.as_ref() {
669 let description = fixture.description.replace('\'', "\\'");
670 let method = http.request.method.to_uppercase();
671 let path = &http.request.path;
672 let _ = writeln!(out, " describe '{method} {path}' do");
673 let _ = writeln!(out, " it '{description}' do");
674 let _ = writeln!(
675 out,
676 " skip 'HTTP 101 WebSocket upgrade cannot be tested via Net::HTTP'"
677 );
678 let _ = writeln!(out, " end");
679 let _ = writeln!(out, " end");
680 }
681 return;
682 }
683
684 client::http_call::render_http_test(out, &RubyTestClientRenderer, fixture);
685}
686
687fn http_method_class(method: &str) -> String {
690 let mut chars = method.chars();
691 match chars.next() {
692 None => String::new(),
693 Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
694 }
695}
696
697#[allow(clippy::too_many_arguments)]
702fn render_example(
703 out: &mut String,
704 fixture: &Fixture,
705 function_name: &str,
706 call_receiver: &str,
707 result_var: &str,
708 args: &[crate::config::ArgMapping],
709 field_resolver: &FieldResolver,
710 options_type: Option<&str>,
711 enum_fields: &HashMap<String, String>,
712 result_is_simple: bool,
713 e2e_config: &E2eConfig,
714 client_factory: Option<&str>,
715) {
716 let test_name = sanitize_ident(&fixture.id);
717 let description = fixture.description.replace('\'', "\\'");
718 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
719
720 let (mut setup_lines, args_str) = build_args_and_setup(
721 &fixture.input,
722 args,
723 call_receiver,
724 options_type,
725 enum_fields,
726 result_is_simple,
727 &fixture.id,
728 );
729
730 let mut visitor_arg = String::new();
732 if let Some(visitor_spec) = &fixture.visitor {
733 visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
734 }
735
736 let final_args = if visitor_arg.is_empty() {
737 args_str
738 } else if args_str.is_empty() {
739 visitor_arg
740 } else {
741 format!("{args_str}, {visitor_arg}")
742 };
743
744 let call_expr = if client_factory.is_some() {
746 format!("client.{function_name}({final_args})")
747 } else {
748 format!("{call_receiver}.{function_name}({final_args})")
749 };
750
751 let _ = writeln!(out, " it '{test_name}: {description}' do");
752
753 if let Some(factory) = client_factory {
755 let fixture_id = &fixture.id;
756 let _ = writeln!(
757 out,
758 " client = {call_receiver}.{factory}('test-key', ENV.fetch('MOCK_SERVER_URL') + '/fixtures/{fixture_id}')"
759 );
760 }
761
762 for line in &setup_lines {
763 let _ = writeln!(out, " {line}");
764 }
765
766 if expects_error {
767 let _ = writeln!(out, " expect {{ {call_expr} }}.to raise_error");
768 let _ = writeln!(out, " end");
769 return;
770 }
771
772 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
774 let _ = writeln!(out, " {result_var} = {call_expr}");
775
776 for assertion in &fixture.assertions {
777 render_assertion(out, assertion, result_var, field_resolver, result_is_simple, e2e_config);
778 }
779
780 if !has_usable {
784 let _ = writeln!(out, " expect({result_var}).not_to be_nil");
785 }
786
787 let _ = writeln!(out, " end");
788}
789
790fn emit_ruby_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
795 if let Some(items) = arr.as_array() {
796 let item_strs: Vec<String> = items
797 .iter()
798 .filter_map(|item| {
799 if let Some(obj) = item.as_object() {
800 match elem_type {
801 "BatchBytesItem" => {
802 let content = obj.get("content").and_then(|v| v.as_array());
803 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
804 let config = obj.get("config");
805 let content_code = if let Some(arr) = content {
806 let bytes: Vec<String> =
807 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
808 format!("[{}]", bytes.join(", "))
810 } else {
811 "[]".to_string()
812 };
813 let config_arg = if let Some(cfg) = config {
814 if cfg.is_null() {
815 "nil".to_string()
816 } else {
817 json_to_ruby(cfg)
818 }
819 } else {
820 "nil".to_string()
821 };
822 Some(format!(
823 "Kreuzberg::{}.new({}, \"{}\", {})",
824 elem_type, content_code, mime_type, config_arg
825 ))
826 }
827 "BatchFileItem" => {
828 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
829 let config = obj.get("config");
830 let config_arg = if let Some(cfg) = config {
831 if cfg.is_null() {
832 "nil".to_string()
833 } else {
834 json_to_ruby(cfg)
835 }
836 } else {
837 "nil".to_string()
838 };
839 Some(format!("Kreuzberg::{}.new(\"{}\", {})", elem_type, path, config_arg))
840 }
841 _ => None,
842 }
843 } else {
844 None
845 }
846 })
847 .collect();
848 format!("[{}]", item_strs.join(", "))
849 } else {
850 "[]".to_string()
851 }
852}
853
854fn build_args_and_setup(
855 input: &serde_json::Value,
856 args: &[crate::config::ArgMapping],
857 call_receiver: &str,
858 options_type: Option<&str>,
859 enum_fields: &HashMap<String, String>,
860 result_is_simple: bool,
861 fixture_id: &str,
862) -> (Vec<String>, String) {
863 if args.is_empty() {
864 let is_empty_input = match input {
868 serde_json::Value::Null => true,
869 serde_json::Value::Object(m) => m.is_empty(),
870 _ => false,
871 };
872 if is_empty_input {
873 return (Vec::new(), String::new());
874 }
875 return (Vec::new(), json_to_ruby(input));
876 }
877
878 let mut setup_lines: Vec<String> = Vec::new();
879 let mut parts: Vec<String> = Vec::new();
880 let mut skipped_optional_count: usize = 0;
883
884 for arg in args {
885 if arg.arg_type == "mock_url" {
886 for _ in 0..skipped_optional_count {
888 parts.push("nil".to_string());
889 }
890 skipped_optional_count = 0;
891 setup_lines.push(format!(
892 "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
893 arg.name,
894 ));
895 parts.push(arg.name.clone());
896 continue;
897 }
898
899 if arg.arg_type == "bytes" {
901 for _ in 0..skipped_optional_count {
903 parts.push("nil".to_string());
904 }
905 skipped_optional_count = 0;
906 let resolved = resolve_field(input, &arg.field);
907 if let Some(s) = resolved.as_str() {
908 if is_file_path(s) {
909 setup_lines.push(format!("{} = File.read(\"{}\").bytes", arg.name, s));
911 } else if is_base64(s) {
912 setup_lines.push(format!("{} = Base64.decode64(\"{}\").bytes", arg.name, s));
914 } else {
915 let escaped = ruby_string_literal(s);
917 setup_lines.push(format!("{} = {}.b.bytes", arg.name, escaped));
918 }
919 parts.push(arg.name.clone());
920 } else {
921 parts.push("nil".to_string());
922 }
923 continue;
924 }
925
926 if arg.arg_type == "file_path" {
928 for _ in 0..skipped_optional_count {
930 parts.push("nil".to_string());
931 }
932 skipped_optional_count = 0;
933 let resolved = resolve_field(input, &arg.field);
934 if let Some(s) = resolved.as_str() {
935 let escaped = ruby_string_literal(s);
936 parts.push(escaped);
937 } else if arg.optional {
938 skipped_optional_count += 1;
939 continue;
940 } else {
941 parts.push("''".to_string());
942 }
943 continue;
944 }
945
946 if arg.arg_type == "handle" {
947 for _ in 0..skipped_optional_count {
949 parts.push("nil".to_string());
950 }
951 skipped_optional_count = 0;
952 let constructor_name = format!("create_{}", arg.name.to_snake_case());
954 let config_value = resolve_field(input, &arg.field);
955 if config_value.is_null()
956 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
957 {
958 setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
959 } else {
960 let literal = json_to_ruby(config_value);
961 let name = &arg.name;
962 setup_lines.push(format!("{name}_config = {literal}"));
963 setup_lines.push(format!(
964 "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
965 arg.name,
966 name = name,
967 ));
968 }
969 parts.push(arg.name.clone());
970 continue;
971 }
972
973 let resolved = resolve_field(input, &arg.field);
974 let val = if resolved.is_null() { None } else { Some(resolved) };
975 match val {
976 None | Some(serde_json::Value::Null) if arg.optional => {
977 skipped_optional_count += 1;
979 continue;
980 }
981 None | Some(serde_json::Value::Null) => {
982 for _ in 0..skipped_optional_count {
984 parts.push("nil".to_string());
985 }
986 skipped_optional_count = 0;
987 let default_val = match arg.arg_type.as_str() {
988 "string" => "''".to_string(),
989 "int" | "integer" => "0".to_string(),
990 "float" | "number" => "0.0".to_string(),
991 "bool" | "boolean" => "false".to_string(),
992 _ => "nil".to_string(),
993 };
994 parts.push(default_val);
995 }
996 Some(v) => {
997 for _ in 0..skipped_optional_count {
999 parts.push("nil".to_string());
1000 }
1001 skipped_optional_count = 0;
1002 if arg.arg_type == "json_object" && !v.is_null() {
1005 if let Some(elem_type) = &arg.element_type {
1007 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1008 parts.push(emit_ruby_batch_item_array(v, elem_type));
1009 continue;
1010 }
1011 }
1012 if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
1014 let kwargs: Vec<String> = obj
1015 .iter()
1016 .map(|(k, vv)| {
1017 let snake_key = k.to_snake_case();
1018 let rb_val = if enum_fields.contains_key(k) {
1019 if let Some(s) = vv.as_str() {
1020 let snake_val = s.to_snake_case();
1021 format!("'{snake_val}'")
1022 } else {
1023 json_to_ruby(vv)
1024 }
1025 } else {
1026 json_to_ruby(vv)
1027 };
1028 format!("{snake_key}: {rb_val}")
1029 })
1030 .collect();
1031 if result_is_simple {
1032 parts.push(format!("{{{}}}", kwargs.join(", ")));
1033 } else {
1034 parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
1035 }
1036 continue;
1037 }
1038 }
1039 parts.push(json_to_ruby(v));
1040 }
1041 }
1042 }
1043
1044 (setup_lines, parts.join(", "))
1045}
1046
1047fn render_assertion(
1048 out: &mut String,
1049 assertion: &Assertion,
1050 result_var: &str,
1051 field_resolver: &FieldResolver,
1052 result_is_simple: bool,
1053 e2e_config: &E2eConfig,
1054) {
1055 if let Some(f) = &assertion.field {
1058 match f.as_str() {
1059 "chunks_have_content" => {
1060 let pred = format!("({result_var}.chunks || []).all? {{ |c| c.content && !c.content.empty? }}");
1061 match assertion.assertion_type.as_str() {
1062 "is_true" => {
1063 let _ = writeln!(out, " expect({pred}).to be(true)");
1064 }
1065 "is_false" => {
1066 let _ = writeln!(out, " expect({pred}).to be(false)");
1067 }
1068 _ => {
1069 let _ = writeln!(
1070 out,
1071 " # skipped: unsupported assertion type on synthetic field '{f}'"
1072 );
1073 }
1074 }
1075 return;
1076 }
1077 "chunks_have_embeddings" => {
1078 let pred =
1079 format!("({result_var}.chunks || []).all? {{ |c| !c.embedding.nil? && !c.embedding.empty? }}");
1080 match assertion.assertion_type.as_str() {
1081 "is_true" => {
1082 let _ = writeln!(out, " expect({pred}).to be(true)");
1083 }
1084 "is_false" => {
1085 let _ = writeln!(out, " expect({pred}).to be(false)");
1086 }
1087 _ => {
1088 let _ = writeln!(
1089 out,
1090 " # skipped: unsupported assertion type on synthetic field '{f}'"
1091 );
1092 }
1093 }
1094 return;
1095 }
1096 "embeddings" => {
1100 match assertion.assertion_type.as_str() {
1101 "count_equals" => {
1102 if let Some(val) = &assertion.value {
1103 let rb_val = json_to_ruby(val);
1104 let _ = writeln!(out, " expect({result_var}.length).to eq({rb_val})");
1105 }
1106 }
1107 "count_min" => {
1108 if let Some(val) = &assertion.value {
1109 let rb_val = json_to_ruby(val);
1110 let _ = writeln!(out, " expect({result_var}.length).to be >= {rb_val}");
1111 }
1112 }
1113 "not_empty" => {
1114 let _ = writeln!(out, " expect({result_var}).not_to be_empty");
1115 }
1116 "is_empty" => {
1117 let _ = writeln!(out, " expect({result_var}).to be_empty");
1118 }
1119 _ => {
1120 let _ = writeln!(
1121 out,
1122 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
1123 );
1124 }
1125 }
1126 return;
1127 }
1128 "embedding_dimensions" => {
1129 let expr = format!("({result_var}.empty? ? 0 : {result_var}[0].length)");
1130 match assertion.assertion_type.as_str() {
1131 "equals" => {
1132 if let Some(val) = &assertion.value {
1133 let rb_val = json_to_ruby(val);
1134 let _ = writeln!(out, " expect({expr}).to eq({rb_val})");
1135 }
1136 }
1137 "greater_than" => {
1138 if let Some(val) = &assertion.value {
1139 let rb_val = json_to_ruby(val);
1140 let _ = writeln!(out, " expect({expr}).to be > {rb_val}");
1141 }
1142 }
1143 _ => {
1144 let _ = writeln!(
1145 out,
1146 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1147 );
1148 }
1149 }
1150 return;
1151 }
1152 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1153 let pred = match f.as_str() {
1154 "embeddings_valid" => {
1155 format!("{result_var}.all? {{ |e| !e.empty? }}")
1156 }
1157 "embeddings_finite" => {
1158 format!("{result_var}.all? {{ |e| e.all? {{ |v| v.finite? }} }}")
1159 }
1160 "embeddings_non_zero" => {
1161 format!("{result_var}.all? {{ |e| e.any? {{ |v| v != 0.0 }} }}")
1162 }
1163 "embeddings_normalized" => {
1164 format!("{result_var}.all? {{ |e| n = e.sum {{ |v| v * v }}; (n - 1.0).abs < 1e-3 }}")
1165 }
1166 _ => unreachable!(),
1167 };
1168 match assertion.assertion_type.as_str() {
1169 "is_true" => {
1170 let _ = writeln!(out, " expect({pred}).to be(true)");
1171 }
1172 "is_false" => {
1173 let _ = writeln!(out, " expect({pred}).to be(false)");
1174 }
1175 _ => {
1176 let _ = writeln!(
1177 out,
1178 " # skipped: unsupported assertion type on synthetic field '{f}'"
1179 );
1180 }
1181 }
1182 return;
1183 }
1184 "keywords" | "keywords_count" => {
1187 let _ = writeln!(out, " # skipped: field '{f}' not available on Ruby ExtractionResult");
1188 return;
1189 }
1190 _ => {}
1191 }
1192 }
1193
1194 if let Some(f) = &assertion.field {
1196 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1197 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1198 return;
1199 }
1200 }
1201
1202 if result_is_simple {
1204 if let Some(f) = &assertion.field {
1205 let f_lower = f.to_lowercase();
1206 if !f.is_empty()
1207 && f_lower != "content"
1208 && (f_lower.starts_with("metadata")
1209 || f_lower.starts_with("document")
1210 || f_lower.starts_with("structure"))
1211 {
1212 return;
1213 }
1214 }
1215 }
1216
1217 let field_expr = match &assertion.field {
1221 Some(f) if !f.is_empty() && (!result_is_simple || !f.eq_ignore_ascii_case("content")) => {
1222 field_resolver.accessor(f, "ruby", result_var)
1223 }
1224 _ => result_var.to_string(),
1225 };
1226
1227 let stripped_field_expr = if result_is_simple {
1230 format!("{field_expr}.to_s.strip")
1231 } else {
1232 field_expr.clone()
1233 };
1234
1235 let field_is_array = assertion
1238 .field
1239 .as_deref()
1240 .filter(|f| !f.is_empty())
1241 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1242
1243 match assertion.assertion_type.as_str() {
1244 "equals" => {
1245 if let Some(expected) = &assertion.value {
1246 if let Some(b) = expected.as_bool() {
1248 let _ = writeln!(out, " expect({stripped_field_expr}).to be({b})");
1249 } else {
1250 let rb_val = json_to_ruby(expected);
1251 let _ = writeln!(out, " expect({stripped_field_expr}).to eq({rb_val})");
1252 }
1253 }
1254 }
1255 "contains" => {
1256 if let Some(expected) = &assertion.value {
1257 let rb_val = json_to_ruby(expected);
1258 if field_is_array && expected.is_string() {
1259 let _ = writeln!(
1261 out,
1262 " expect({field_expr}.any? {{ |item| alef_e2e_item_texts(item).any? {{ |t| t.include?({rb_val}) }} }}).to be(true)"
1263 );
1264 } else {
1265 let _ = writeln!(out, " expect({field_expr}.to_s).to include({rb_val})");
1267 }
1268 }
1269 }
1270 "contains_all" => {
1271 if let Some(values) = &assertion.values {
1272 for val in values {
1273 let rb_val = json_to_ruby(val);
1274 if field_is_array && val.is_string() {
1275 let _ = writeln!(
1276 out,
1277 " expect({field_expr}.any? {{ |item| alef_e2e_item_texts(item).any? {{ |t| t.include?({rb_val}) }} }}).to be(true)"
1278 );
1279 } else {
1280 let _ = writeln!(out, " expect({field_expr}.to_s).to include({rb_val})");
1281 }
1282 }
1283 }
1284 }
1285 "not_contains" => {
1286 if let Some(expected) = &assertion.value {
1287 let rb_val = json_to_ruby(expected);
1288 if field_is_array && expected.is_string() {
1289 let _ = writeln!(
1290 out,
1291 " expect({field_expr}.any? {{ |item| alef_e2e_item_texts(item).any? {{ |t| t.include?({rb_val}) }} }}).to be(false)"
1292 );
1293 } else {
1294 let _ = writeln!(out, " expect({field_expr}.to_s).not_to include({rb_val})");
1295 }
1296 }
1297 }
1298 "not_empty" => {
1299 if result_is_simple {
1300 let _ = writeln!(out, " expect({field_expr}.to_s).not_to be_empty");
1301 } else {
1302 let _ = writeln!(out, " expect({field_expr}).not_to be_empty");
1303 }
1304 }
1305 "is_empty" => {
1306 let _ = writeln!(out, " expect({field_expr}.nil? || {field_expr}.empty?).to be(true)");
1308 }
1309 "contains_any" => {
1310 if let Some(values) = &assertion.values {
1311 let items: Vec<String> = values.iter().map(json_to_ruby).collect();
1312 let arr_str = items.join(", ");
1313 let _ = writeln!(
1314 out,
1315 " expect([{arr_str}].any? {{ |v| {field_expr}.to_s.include?(v) }}).to be(true)"
1316 );
1317 }
1318 }
1319 "greater_than" => {
1320 if let Some(val) = &assertion.value {
1321 let rb_val = json_to_ruby(val);
1322 let _ = writeln!(out, " expect({field_expr}).to be > {rb_val}");
1323 }
1324 }
1325 "less_than" => {
1326 if let Some(val) = &assertion.value {
1327 let rb_val = json_to_ruby(val);
1328 let _ = writeln!(out, " expect({field_expr}).to be < {rb_val}");
1329 }
1330 }
1331 "greater_than_or_equal" => {
1332 if let Some(val) = &assertion.value {
1333 let rb_val = json_to_ruby(val);
1334 let _ = writeln!(out, " expect({field_expr}).to be >= {rb_val}");
1335 }
1336 }
1337 "less_than_or_equal" => {
1338 if let Some(val) = &assertion.value {
1339 let rb_val = json_to_ruby(val);
1340 let _ = writeln!(out, " expect({field_expr}).to be <= {rb_val}");
1341 }
1342 }
1343 "starts_with" => {
1344 if let Some(expected) = &assertion.value {
1345 let rb_val = json_to_ruby(expected);
1346 let _ = writeln!(out, " expect({field_expr}).to start_with({rb_val})");
1347 }
1348 }
1349 "ends_with" => {
1350 if let Some(expected) = &assertion.value {
1351 let rb_val = json_to_ruby(expected);
1352 let _ = writeln!(out, " expect({field_expr}).to end_with({rb_val})");
1353 }
1354 }
1355 "min_length" => {
1356 if let Some(val) = &assertion.value {
1357 if let Some(n) = val.as_u64() {
1358 let _ = writeln!(out, " expect({field_expr}.length).to be >= {n}");
1359 }
1360 }
1361 }
1362 "max_length" => {
1363 if let Some(val) = &assertion.value {
1364 if let Some(n) = val.as_u64() {
1365 let _ = writeln!(out, " expect({field_expr}.length).to be <= {n}");
1366 }
1367 }
1368 }
1369 "count_min" => {
1370 if let Some(val) = &assertion.value {
1371 if let Some(n) = val.as_u64() {
1372 let _ = writeln!(out, " expect({field_expr}.length).to be >= {n}");
1373 }
1374 }
1375 }
1376 "count_equals" => {
1377 if let Some(val) = &assertion.value {
1378 if let Some(n) = val.as_u64() {
1379 let _ = writeln!(out, " expect({field_expr}.length).to eq({n})");
1380 }
1381 }
1382 }
1383 "is_true" => {
1384 let _ = writeln!(out, " expect({field_expr}).to be true");
1385 }
1386 "is_false" => {
1387 let _ = writeln!(out, " expect({field_expr}).to be false");
1388 }
1389 "method_result" => {
1390 if let Some(method_name) = &assertion.method {
1391 let lang = "ruby";
1393 let call = &e2e_config.call;
1394 let overrides = call.overrides.get(lang);
1395 let module_path = overrides
1396 .and_then(|o| o.module.as_ref())
1397 .cloned()
1398 .unwrap_or_else(|| call.module.clone());
1399 let call_receiver = ruby_module_name(&module_path);
1400
1401 let call_expr =
1402 build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
1403 let check = assertion.check.as_deref().unwrap_or("is_true");
1404 match check {
1405 "equals" => {
1406 if let Some(val) = &assertion.value {
1407 if let Some(b) = val.as_bool() {
1408 let _ = writeln!(out, " expect({call_expr}).to be {b}");
1409 } else {
1410 let rb_val = json_to_ruby(val);
1411 let _ = writeln!(out, " expect({call_expr}).to eq({rb_val})");
1412 }
1413 }
1414 }
1415 "is_true" => {
1416 let _ = writeln!(out, " expect({call_expr}).to be true");
1417 }
1418 "is_false" => {
1419 let _ = writeln!(out, " expect({call_expr}).to be false");
1420 }
1421 "greater_than_or_equal" => {
1422 if let Some(val) = &assertion.value {
1423 let rb_val = json_to_ruby(val);
1424 let _ = writeln!(out, " expect({call_expr}).to be >= {rb_val}");
1425 }
1426 }
1427 "count_min" => {
1428 if let Some(val) = &assertion.value {
1429 let n = val.as_u64().unwrap_or(0);
1430 let _ = writeln!(out, " expect({call_expr}.length).to be >= {n}");
1431 }
1432 }
1433 "is_error" => {
1434 let _ = writeln!(out, " expect {{ {call_expr} }}.to raise_error");
1435 }
1436 "contains" => {
1437 if let Some(val) = &assertion.value {
1438 let rb_val = json_to_ruby(val);
1439 let _ = writeln!(out, " expect({call_expr}).to include({rb_val})");
1440 }
1441 }
1442 other_check => {
1443 panic!("Ruby e2e generator: unsupported method_result check type: {other_check}");
1444 }
1445 }
1446 } else {
1447 panic!("Ruby e2e generator: method_result assertion missing 'method' field");
1448 }
1449 }
1450 "matches_regex" => {
1451 if let Some(expected) = &assertion.value {
1452 let rb_val = json_to_ruby(expected);
1453 let _ = writeln!(out, " expect({field_expr}).to match({rb_val})");
1454 }
1455 }
1456 "not_error" => {
1457 }
1459 "error" => {
1460 }
1462 other => {
1463 panic!("Ruby e2e generator: unsupported assertion type: {other}");
1464 }
1465 }
1466}
1467
1468fn build_ruby_method_call(
1471 call_receiver: &str,
1472 result_var: &str,
1473 method_name: &str,
1474 args: Option<&serde_json::Value>,
1475) -> String {
1476 match method_name {
1477 "root_child_count" => format!("{result_var}.root_node.child_count"),
1478 "root_node_type" => format!("{result_var}.root_node.type"),
1479 "named_children_count" => format!("{result_var}.root_node.named_child_count"),
1480 "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
1481 "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
1482 "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
1483 "contains_node_type" => {
1484 let node_type = args
1485 .and_then(|a| a.get("node_type"))
1486 .and_then(|v| v.as_str())
1487 .unwrap_or("");
1488 format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
1489 }
1490 "find_nodes_by_type" => {
1491 let node_type = args
1492 .and_then(|a| a.get("node_type"))
1493 .and_then(|v| v.as_str())
1494 .unwrap_or("");
1495 format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
1496 }
1497 "run_query" => {
1498 let query_source = args
1499 .and_then(|a| a.get("query_source"))
1500 .and_then(|v| v.as_str())
1501 .unwrap_or("");
1502 let language = args
1503 .and_then(|a| a.get("language"))
1504 .and_then(|v| v.as_str())
1505 .unwrap_or("");
1506 format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1507 }
1508 _ => format!("{result_var}.{method_name}"),
1509 }
1510}
1511
1512fn ruby_module_name(module_path: &str) -> String {
1515 use heck::ToUpperCamelCase;
1516 module_path.to_upper_camel_case()
1517}
1518
1519fn json_to_ruby(value: &serde_json::Value) -> String {
1521 match value {
1522 serde_json::Value::String(s) => ruby_string_literal(s),
1523 serde_json::Value::Bool(true) => "true".to_string(),
1524 serde_json::Value::Bool(false) => "false".to_string(),
1525 serde_json::Value::Number(n) => n.to_string(),
1526 serde_json::Value::Null => "nil".to_string(),
1527 serde_json::Value::Array(arr) => {
1528 let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
1529 format!("[{}]", items.join(", "))
1530 }
1531 serde_json::Value::Object(map) => {
1532 let items: Vec<String> = map
1533 .iter()
1534 .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
1535 .collect();
1536 format!("{{ {} }}", items.join(", "))
1537 }
1538 }
1539}
1540
1541fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1547 setup_lines.push("visitor = Class.new do".to_string());
1548 for (method_name, action) in &visitor_spec.callbacks {
1549 emit_ruby_visitor_method(setup_lines, method_name, action);
1550 }
1551 setup_lines.push("end.new".to_string());
1552 "visitor".to_string()
1553}
1554
1555fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1557 let snake_method = method_name;
1558 let params = match method_name {
1559 "visit_link" => "ctx, href, text, title",
1560 "visit_image" => "ctx, src, alt, title",
1561 "visit_heading" => "ctx, level, text, id",
1562 "visit_code_block" => "ctx, lang, code",
1563 "visit_code_inline"
1564 | "visit_strong"
1565 | "visit_emphasis"
1566 | "visit_strikethrough"
1567 | "visit_underline"
1568 | "visit_subscript"
1569 | "visit_superscript"
1570 | "visit_mark"
1571 | "visit_button"
1572 | "visit_summary"
1573 | "visit_figcaption"
1574 | "visit_definition_term"
1575 | "visit_definition_description" => "ctx, text",
1576 "visit_text" => "ctx, text",
1577 "visit_list_item" => "ctx, ordered, marker, text",
1578 "visit_blockquote" => "ctx, content, depth",
1579 "visit_table_row" => "ctx, cells, is_header",
1580 "visit_custom_element" => "ctx, tag_name, html",
1581 "visit_form" => "ctx, action_url, method",
1582 "visit_input" => "ctx, input_type, name, value",
1583 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1584 "visit_details" => "ctx, is_open",
1585 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1586 "visit_list_start" => "ctx, ordered",
1587 "visit_list_end" => "ctx, ordered, output",
1588 _ => "ctx",
1589 };
1590
1591 setup_lines.push(format!(" def {snake_method}({params})"));
1592 match action {
1593 CallbackAction::Skip => {
1594 setup_lines.push(" 'skip'".to_string());
1595 }
1596 CallbackAction::Continue => {
1597 setup_lines.push(" 'continue'".to_string());
1598 }
1599 CallbackAction::PreserveHtml => {
1600 setup_lines.push(" 'preserve_html'".to_string());
1601 }
1602 CallbackAction::Custom { output } => {
1603 let escaped = ruby_string_literal(output);
1604 setup_lines.push(format!(" {{ custom: {escaped} }}"));
1605 }
1606 CallbackAction::CustomTemplate { template } => {
1607 let interpolated = ruby_template_to_interpolation(template);
1608 setup_lines.push(format!(" {{ custom: \"{interpolated}\" }}"));
1609 }
1610 }
1611 setup_lines.push(" end".to_string());
1612}
1613
1614fn is_file_path(s: &str) -> bool {
1619 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1620 return false;
1621 }
1622
1623 let first = s.chars().next().unwrap_or('\0');
1624 if first.is_ascii_alphanumeric() || first == '_' {
1625 if let Some(slash_pos) = s.find('/') {
1626 if slash_pos > 0 {
1627 let after_slash = &s[slash_pos + 1..];
1628 if after_slash.contains('.') && !after_slash.is_empty() {
1629 return true;
1630 }
1631 }
1632 }
1633 }
1634
1635 false
1636}
1637
1638fn is_base64(s: &str) -> bool {
1641 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1642 return false;
1643 }
1644
1645 if is_file_path(s) {
1646 return false;
1647 }
1648
1649 true
1650}