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::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23pub struct RubyCodegen;
25
26impl E2eCodegen for RubyCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 config: &ResolvedCrateConfig,
32 ) -> Result<Vec<GeneratedFile>> {
33 let lang = self.language_name();
34 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36 let mut files = Vec::new();
37
38 let call = &e2e_config.call;
40 let overrides = call.overrides.get(lang);
41 let module_path = overrides
42 .and_then(|o| o.module.as_ref())
43 .cloned()
44 .unwrap_or_else(|| call.module.clone());
45 let class_name = overrides.and_then(|o| o.class.as_ref()).cloned();
46 let options_type = overrides.and_then(|o| o.options_type.clone());
47 let empty_enum_fields = HashMap::new();
48 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
49 let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
50
51 let ruby_pkg = e2e_config.resolve_package("ruby");
53 let gem_name = ruby_pkg
54 .as_ref()
55 .and_then(|p| p.name.as_ref())
56 .cloned()
57 .unwrap_or_else(|| config.name.replace('-', "_"));
58 let gem_path = ruby_pkg
59 .as_ref()
60 .and_then(|p| p.path.as_ref())
61 .cloned()
62 .unwrap_or_else(|| "../../packages/ruby".to_string());
63 let gem_version = ruby_pkg
64 .as_ref()
65 .and_then(|p| p.version.as_ref())
66 .cloned()
67 .or_else(|| config.resolved_version())
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
86 .iter()
87 .flat_map(|g| g.fixtures.iter())
88 .any(|f| f.needs_mock_server());
89
90 let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
92 let cc = e2e_config.resolve_call(f.call.as_deref());
93 cc.args
94 .iter()
95 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
96 });
97
98 if has_file_fixtures || has_http_fixtures {
100 files.push(GeneratedFile {
101 path: output_base.join("spec").join("spec_helper.rb"),
102 content: render_spec_helper(has_file_fixtures, has_http_fixtures),
103 generated_header: true,
104 });
105 }
106
107 let spec_base = output_base.join("spec");
109
110 for group in groups {
111 let active: Vec<&Fixture> = group
112 .fixtures
113 .iter()
114 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
115 .collect();
116
117 if active.is_empty() {
118 continue;
119 }
120
121 let field_resolver_pre = FieldResolver::new(
122 &e2e_config.fields,
123 &e2e_config.fields_optional,
124 &e2e_config.result_fields,
125 &e2e_config.fields_array,
126 &std::collections::HashSet::new(),
127 );
128 let has_any_output = active.iter().any(|f| {
130 if f.is_http_test() {
132 return true;
133 }
134 let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
135 let has_not_error = f.assertions.iter().any(|a| a.assertion_type == "not_error");
136 expects_error || has_not_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
137 });
138 if !has_any_output {
139 continue;
140 }
141
142 let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
143 let field_resolver = FieldResolver::new(
144 &e2e_config.fields,
145 &e2e_config.fields_optional,
146 &e2e_config.result_fields,
147 &e2e_config.fields_array,
148 &std::collections::HashSet::new(),
149 );
150 let content = render_spec_file(
151 &group.category,
152 &active,
153 &module_path,
154 class_name.as_deref(),
155 &gem_name,
156 &field_resolver,
157 options_type.as_deref(),
158 enum_fields,
159 result_is_simple,
160 e2e_config,
161 has_file_fixtures || has_http_fixtures,
162 );
163 files.push(GeneratedFile {
164 path: spec_base.join(filename),
165 content,
166 generated_header: true,
167 });
168 }
169
170 Ok(files)
171 }
172
173 fn language_name(&self) -> &'static str {
174 "ruby"
175 }
176}
177
178fn render_gemfile(
183 gem_name: &str,
184 gem_path: &str,
185 gem_version: &str,
186 dep_mode: crate::config::DependencyMode,
187) -> String {
188 let gem_line = match dep_mode {
189 crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
190 crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
191 };
192 crate::template_env::render(
193 "ruby/Gemfile.jinja",
194 minijinja::context! {
195 gem_line => gem_line,
196 rspec => tv::gem::RSPEC_E2E,
197 rubocop => tv::gem::RUBOCOP_E2E,
198 rubocop_rspec => tv::gem::RUBOCOP_RSPEC_E2E,
199 faraday => tv::gem::FARADAY,
200 },
201 )
202}
203
204fn render_spec_helper(has_file_fixtures: bool, has_http_fixtures: bool) -> String {
205 let header = hash::header(CommentStyle::Hash);
206 let mut out = header;
207 out.push_str("# frozen_string_literal: true\n");
208
209 if has_file_fixtures {
210 out.push_str(
211 r#"
212# Change to the test_documents directory so that fixture file paths like
213# "pdf/fake_memo.pdf" resolve correctly when running rspec from e2e/ruby/.
214# spec_helper.rb lives in e2e/ruby/spec/; test_documents lives at the
215# repository root, three directories up: spec/ -> e2e/ruby/ -> e2e/ -> root.
216_test_documents = File.expand_path('../../../test_documents', __dir__)
217Dir.chdir(_test_documents) if Dir.exist?(_test_documents)
218"#,
219 );
220 }
221
222 if has_http_fixtures {
223 out.push_str(
224 r#"
225require 'open3'
226
227# Spawn the mock-server binary and set MOCK_SERVER_URL for all tests.
228RSpec.configure do |config|
229 config.before(:suite) do
230 bin = File.expand_path('../../rust/target/release/mock-server', __dir__)
231 fixtures_dir = File.expand_path('../../../fixtures', __dir__)
232 unless File.exist?(bin)
233 warn "mock-server binary not found at #{bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
234 end
235 stdin, stdout, _stderr, _wait = Open3.popen3(bin, fixtures_dir)
236 url = stdout.readline.strip.split('=', 2).last
237 ENV['MOCK_SERVER_URL'] = url
238 # Drain stdout in background.
239 Thread.new { stdout.read }
240 # Store stdin so we can close it on teardown.
241 @_mock_server_stdin = stdin
242 end
243
244 config.after(:suite) do
245 @_mock_server_stdin&.close
246 end
247end
248"#,
249 );
250 }
251
252 out
253}
254
255fn render_rubocop_yaml() -> String {
256 crate::template_env::render("ruby/rubocop.yml.jinja", minijinja::context! {})
257}
258
259#[allow(clippy::too_many_arguments)]
260fn render_spec_file(
261 category: &str,
262 fixtures: &[&Fixture],
263 module_path: &str,
264 class_name: Option<&str>,
265 gem_name: &str,
266 field_resolver: &FieldResolver,
267 options_type: Option<&str>,
268 enum_fields: &HashMap<String, String>,
269 result_is_simple: bool,
270 e2e_config: &E2eConfig,
271 needs_spec_helper: bool,
272) -> String {
273 let client_factory = e2e_config
275 .call
276 .overrides
277 .get("ruby")
278 .and_then(|o| o.client_factory.as_deref());
279
280 let require_name = if module_path.is_empty() { gem_name } else { module_path };
282 let mut requires = vec![require_name.replace('-', "_"), "json".to_string()];
283
284 let has_http = fixtures.iter().any(|f| f.is_http_test());
285 if needs_spec_helper || has_http {
286 requires.push("spec_helper".to_string());
287 }
288
289 let call_receiver = class_name
291 .map(|s| s.to_string())
292 .unwrap_or_else(|| ruby_module_name(module_path));
293
294 let has_array_contains = fixtures.iter().any(|fixture| {
296 fixture.assertions.iter().any(|a| {
297 matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
298 && a.field
299 .as_deref()
300 .is_some_and(|f| !f.is_empty() && field_resolver.is_array(field_resolver.resolve(f)))
301 })
302 });
303
304 let mut examples = Vec::new();
306 for fixture in fixtures {
307 if fixture.http.is_some() {
308 let mut out = String::new();
310 render_http_example(&mut out, fixture);
311 examples.push(out);
312 } else {
313 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
315 let _has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
316 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
317 if !expects_error && !has_usable {
318 let test_name = sanitize_ident(&fixture.id);
319 let description = fixture.description.replace('\'', "\\'");
320 let mut out = String::new();
321 out.push_str(&format!(" it '{test_name}: {description}' do\n"));
322 out.push_str(" skip 'Non-HTTP fixture cannot be tested via Net::HTTP'\n");
323 out.push_str(" end\n");
324 examples.push(out);
325 } else {
326 let fixture_call = e2e_config.resolve_call(fixture.call.as_deref());
328 let fixture_call_overrides = fixture_call.overrides.get("ruby");
329 let raw_function_name = fixture_call_overrides
330 .and_then(|o| o.function.as_ref())
331 .cloned()
332 .unwrap_or_else(|| fixture_call.function.clone());
333 let fixture_function_name = if fixture_call.r#async && !raw_function_name.ends_with("_async") {
336 format!("{raw_function_name}_async")
337 } else {
338 raw_function_name
339 };
340 let fixture_result_var = &fixture_call.result_var;
341 let fixture_args = &fixture_call.args;
342 let fixture_client_factory = fixture_call_overrides
343 .and_then(|o| o.client_factory.as_deref())
344 .or(client_factory);
345 let fixture_options_type = fixture_call_overrides
346 .and_then(|o| o.options_type.as_deref())
347 .or(options_type);
348
349 let fixture_extra_args: Vec<String> =
350 fixture_call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
351 let fixture_result_is_simple =
354 fixture_call.result_is_simple || fixture_call_overrides.is_some_and(|o| o.result_is_simple);
355 let example = render_example(
356 fixture,
357 &fixture_function_name,
358 &call_receiver,
359 fixture_result_var,
360 fixture_args,
361 field_resolver,
362 fixture_options_type,
363 enum_fields,
364 fixture_result_is_simple,
365 e2e_config,
366 fixture_client_factory,
367 &fixture_extra_args,
368 );
369 examples.push(example);
370 }
371 }
372 }
373
374 let header = hash::header(CommentStyle::Hash);
375 crate::template_env::render(
376 "ruby/test_file.jinja",
377 minijinja::context! {
378 category => category,
379 requires => requires,
380 has_array_contains => has_array_contains,
381 has_http => has_http,
382 examples => examples,
383 header => header,
384 },
385 )
386}
387
388fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
391 fixture.assertions.iter().any(|a| {
392 if a.assertion_type == "not_error" || a.assertion_type == "error" {
394 return false;
395 }
396 if let Some(f) = &a.field {
398 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
399 return false;
400 }
401 if result_is_simple {
403 let f_lower = f.to_lowercase();
404 if !f.is_empty()
405 && f_lower != "content"
406 && (f_lower.starts_with("metadata")
407 || f_lower.starts_with("document")
408 || f_lower.starts_with("structure"))
409 {
410 return false;
411 }
412 }
413 }
414 true
415 })
416}
417
418struct RubyTestClientRenderer;
426
427impl client::TestClientRenderer for RubyTestClientRenderer {
428 fn language_name(&self) -> &'static str {
429 "ruby"
430 }
431
432 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
438 let escaped_description = description.replace('\'', "\\'");
439 let rendered = crate::template_env::render(
440 "ruby/http_test.jinja",
441 minijinja::context! {
442 fn_name => fn_name,
443 description => escaped_description,
444 skip_reason => skip_reason,
445 },
446 );
447 out.push_str(&rendered);
448 }
449
450 fn render_test_close(&self, out: &mut String) {
452 let rendered = crate::template_env::render("ruby/http_test_close.jinja", minijinja::context! {});
453 out.push_str(&rendered);
454 }
455
456 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
458 let method = ctx.method.to_uppercase();
459 let method_class = http_method_class(&method);
460
461 let has_body = ctx
462 .body
463 .is_some_and(|b| !matches!(b, serde_json::Value::String(s) if s.is_empty()));
464
465 let ruby_body = if has_body {
466 json_to_ruby(ctx.body.unwrap())
467 } else {
468 String::new()
469 };
470
471 let headers: Vec<minijinja::Value> = ctx
472 .headers
473 .iter()
474 .filter(|(k, _)| {
475 !(has_body && k.to_lowercase() == "content-type")
477 })
478 .map(|(k, v)| {
479 minijinja::context! {
480 key_literal => ruby_string_literal(k),
481 value_literal => ruby_string_literal(v),
482 }
483 })
484 .collect();
485
486 let rendered = crate::template_env::render(
487 "ruby/http_request.jinja",
488 minijinja::context! {
489 method_class => method_class,
490 path => ctx.path,
491 has_body => has_body,
492 ruby_body => ruby_body,
493 headers => headers,
494 response_var => ctx.response_var,
495 },
496 );
497 out.push_str(&rendered);
498 }
499
500 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
505 out.push_str(&format!(" expect({response_var}.code.to_i).to eq({status})\n"));
506 }
507
508 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
512 let header_key = name.to_lowercase();
513 let header_expr = format!("{response_var}[{}]", ruby_string_literal(&header_key));
514 let assertion = match expected {
515 "<<present>>" => {
516 format!(" expect({header_expr}).not_to be_nil\n")
517 }
518 "<<absent>>" => {
519 format!(" expect({header_expr}).to be_nil\n")
520 }
521 "<<uuid>>" => {
522 format!(
523 " 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)\n"
524 )
525 }
526 literal => {
527 let ruby_val = ruby_string_literal(literal);
528 format!(" expect({header_expr}).to eq({ruby_val})\n")
529 }
530 };
531 out.push_str(&assertion);
532 }
533
534 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
539 match expected {
540 serde_json::Value::String(s) => {
541 let ruby_val = ruby_string_literal(s);
542 out.push_str(&format!(" expect({response_var}.body).to eq({ruby_val})\n"));
543 }
544 _ => {
545 let ruby_val = json_to_ruby(expected);
546 out.push_str(&format!(
547 " _body = {response_var}.body && !{response_var}.body.empty? ? JSON.parse({response_var}.body) : nil\n"
548 ));
549 out.push_str(&format!(" expect(_body).to eq({ruby_val})\n"));
550 }
551 }
552 }
553
554 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
556 if let Some(obj) = expected.as_object() {
557 out.push_str(&format!(" _body = JSON.parse({response_var}.body)\n"));
558 for (key, val) in obj {
559 let ruby_key = ruby_string_literal(key);
560 let ruby_val = json_to_ruby(val);
561 out.push_str(&format!(" expect(_body[{ruby_key}]).to eq({ruby_val})\n"));
562 }
563 }
564 }
565
566 fn render_assert_validation_errors(
569 &self,
570 out: &mut String,
571 response_var: &str,
572 errors: &[ValidationErrorExpectation],
573 ) {
574 for err in errors {
575 let msg_lit = ruby_string_literal(&err.msg);
576 out.push_str(&format!(" _body = JSON.parse({response_var}.body)\n"));
577 out.push_str(" _errors = _body['errors'] || []\n");
578 out.push_str(&format!(
579 " expect(_errors.map {{ |e| e['msg'] }}).to include({msg_lit})\n"
580 ));
581 }
582 }
583}
584
585fn render_http_example(out: &mut String, fixture: &Fixture) {
591 if fixture
595 .http
596 .as_ref()
597 .is_some_and(|h| h.expected_response.status_code == 101)
598 {
599 if let Some(http) = fixture.http.as_ref() {
600 let description = fixture.description.replace('\'', "\\'");
601 let method = http.request.method.to_uppercase();
602 let path = &http.request.path;
603 let rendered = crate::template_env::render(
604 "ruby/http_101_skip.jinja",
605 minijinja::context! {
606 method => method,
607 path => path,
608 description => description,
609 },
610 );
611 out.push_str(&rendered);
612 }
613 return;
614 }
615
616 client::http_call::render_http_test(out, &RubyTestClientRenderer, fixture);
617}
618
619fn http_method_class(method: &str) -> String {
622 let mut chars = method.chars();
623 match chars.next() {
624 None => String::new(),
625 Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
626 }
627}
628
629#[allow(clippy::too_many_arguments)]
634fn render_example(
635 fixture: &Fixture,
636 function_name: &str,
637 call_receiver: &str,
638 result_var: &str,
639 args: &[crate::config::ArgMapping],
640 field_resolver: &FieldResolver,
641 options_type: Option<&str>,
642 enum_fields: &HashMap<String, String>,
643 result_is_simple: bool,
644 e2e_config: &E2eConfig,
645 client_factory: Option<&str>,
646 extra_args: &[String],
647) -> String {
648 let test_name = sanitize_ident(&fixture.id);
649 let description = fixture.description.replace('\'', "\\'");
650 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
651 let fixture_id = fixture.id.clone();
652
653 let (mut setup_lines, args_str) = build_args_and_setup(
654 &fixture.input,
655 args,
656 call_receiver,
657 options_type,
658 enum_fields,
659 result_is_simple,
660 &fixture.id,
661 );
662
663 let mut visitor_arg = String::new();
665 if let Some(visitor_spec) = &fixture.visitor {
666 visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
667 }
668
669 let mut final_args = if visitor_arg.is_empty() {
670 args_str
671 } else if args_str.is_empty() {
672 visitor_arg
673 } else {
674 format!("{args_str}, {visitor_arg}")
675 };
676
677 if !extra_args.is_empty() {
679 let extra_str = extra_args.join(", ");
680 if final_args.is_empty() {
681 final_args = extra_str;
682 } else {
683 final_args = format!("{final_args}, {extra_str}");
684 }
685 }
686
687 let call_expr = if client_factory.is_some() {
689 format!("client.{function_name}({final_args})")
690 } else {
691 format!("{call_receiver}.{function_name}({final_args})")
692 };
693
694 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
696
697 let mut assertions_rendered = String::new();
699 for assertion in &fixture.assertions {
700 render_assertion(
701 &mut assertions_rendered,
702 assertion,
703 result_var,
704 field_resolver,
705 result_is_simple,
706 e2e_config,
707 );
708 }
709
710 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
711 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
712 crate::template_env::render(
713 "ruby/test_function.jinja",
714 minijinja::context! {
715 test_name => test_name,
716 description => description,
717 expects_error => expects_error,
718 setup_lines => setup_lines,
719 call_expr => call_expr,
720 result_var => result_var,
721 assertions_rendered => assertions_rendered,
722 has_usable => has_usable,
723 client_factory => client_factory,
724 fixture_id => fixture_id,
725 call_receiver => call_receiver,
726 has_mock => has_mock,
727 api_key_var => api_key_var,
728 },
729 )
730}
731
732fn emit_ruby_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
737 if let Some(items) = arr.as_array() {
738 let item_strs: Vec<String> = items
739 .iter()
740 .filter_map(|item| {
741 if let Some(obj) = item.as_object() {
742 match elem_type {
743 "BatchBytesItem" => {
744 let content = obj.get("content").and_then(|v| v.as_array());
745 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
746 let config = obj.get("config");
747 let content_code = if let Some(arr) = content {
748 let bytes: Vec<String> =
749 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
750 format!("[{}]", bytes.join(", "))
752 } else {
753 "[]".to_string()
754 };
755 let config_arg = if let Some(cfg) = config {
756 if cfg.is_null() {
757 "nil".to_string()
758 } else {
759 json_to_ruby(cfg)
760 }
761 } else {
762 "nil".to_string()
763 };
764 Some(format!(
765 "Kreuzberg::{}.new(content: {}, mime_type: \"{}\", config: {})",
766 elem_type, content_code, mime_type, config_arg
767 ))
768 }
769 "BatchFileItem" => {
770 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
771 let config = obj.get("config");
772 let config_arg = if let Some(cfg) = config {
773 if cfg.is_null() {
774 "nil".to_string()
775 } else {
776 json_to_ruby(cfg)
777 }
778 } else {
779 "nil".to_string()
780 };
781 Some(format!(
782 "Kreuzberg::{}.new(path: \"{}\", config: {})",
783 elem_type, path, config_arg
784 ))
785 }
786 _ => None,
787 }
788 } else {
789 None
790 }
791 })
792 .collect();
793 format!("[{}]", item_strs.join(", "))
794 } else {
795 "[]".to_string()
796 }
797}
798
799fn build_args_and_setup(
800 input: &serde_json::Value,
801 args: &[crate::config::ArgMapping],
802 call_receiver: &str,
803 options_type: Option<&str>,
804 enum_fields: &HashMap<String, String>,
805 result_is_simple: bool,
806 fixture_id: &str,
807) -> (Vec<String>, String) {
808 if args.is_empty() {
809 let is_empty_input = match input {
813 serde_json::Value::Null => true,
814 serde_json::Value::Object(m) => m.is_empty(),
815 _ => false,
816 };
817 if is_empty_input {
818 return (Vec::new(), String::new());
819 }
820 return (Vec::new(), json_to_ruby(input));
821 }
822
823 let mut setup_lines: Vec<String> = Vec::new();
824 let mut parts: Vec<String> = Vec::new();
825 let mut skipped_optional_count: usize = 0;
828
829 for arg in args {
830 if arg.arg_type == "mock_url" {
831 for _ in 0..skipped_optional_count {
833 parts.push("nil".to_string());
834 }
835 skipped_optional_count = 0;
836 setup_lines.push(format!(
837 "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
838 arg.name,
839 ));
840 parts.push(arg.name.clone());
841 continue;
842 }
843
844 if arg.arg_type == "bytes" {
846 for _ in 0..skipped_optional_count {
848 parts.push("nil".to_string());
849 }
850 skipped_optional_count = 0;
851 let resolved = resolve_field(input, &arg.field);
852 if let Some(s) = resolved.as_str() {
853 if is_file_path(s) {
854 setup_lines.push(format!("{} = File.read(\"{}\").bytes", arg.name, s));
856 } else if is_base64(s) {
857 setup_lines.push(format!("{} = Base64.decode64(\"{}\").bytes", arg.name, s));
859 } else {
860 let escaped = ruby_string_literal(s);
862 setup_lines.push(format!("{} = {}.b.bytes", arg.name, escaped));
863 }
864 parts.push(arg.name.clone());
865 } else {
866 parts.push("nil".to_string());
867 }
868 continue;
869 }
870
871 if arg.arg_type == "file_path" {
873 for _ in 0..skipped_optional_count {
875 parts.push("nil".to_string());
876 }
877 skipped_optional_count = 0;
878 let resolved = resolve_field(input, &arg.field);
879 if let Some(s) = resolved.as_str() {
880 let escaped = ruby_string_literal(s);
881 parts.push(escaped);
882 } else if arg.optional {
883 skipped_optional_count += 1;
884 continue;
885 } else {
886 parts.push("''".to_string());
887 }
888 continue;
889 }
890
891 if arg.arg_type == "handle" {
892 for _ in 0..skipped_optional_count {
894 parts.push("nil".to_string());
895 }
896 skipped_optional_count = 0;
897 let constructor_name = format!("create_{}", arg.name.to_snake_case());
899 let config_value = resolve_field(input, &arg.field);
900 if config_value.is_null()
901 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
902 {
903 setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
904 } else {
905 let literal = json_to_ruby(config_value);
906 let name = &arg.name;
907 setup_lines.push(format!("{name}_config = {literal}"));
908 setup_lines.push(format!(
909 "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
910 arg.name,
911 name = name,
912 ));
913 }
914 parts.push(arg.name.clone());
915 continue;
916 }
917
918 let resolved = resolve_field(input, &arg.field);
919 let val = if resolved.is_null() { None } else { Some(resolved) };
920 match val {
921 None | Some(serde_json::Value::Null) if arg.optional => {
922 skipped_optional_count += 1;
924 continue;
925 }
926 None | Some(serde_json::Value::Null) => {
927 for _ in 0..skipped_optional_count {
929 parts.push("nil".to_string());
930 }
931 skipped_optional_count = 0;
932 let default_val = match arg.arg_type.as_str() {
933 "string" => "''".to_string(),
934 "int" | "integer" => "0".to_string(),
935 "float" | "number" => "0.0".to_string(),
936 "bool" | "boolean" => "false".to_string(),
937 _ => "nil".to_string(),
938 };
939 parts.push(default_val);
940 }
941 Some(v) => {
942 for _ in 0..skipped_optional_count {
944 parts.push("nil".to_string());
945 }
946 skipped_optional_count = 0;
947 if arg.arg_type == "json_object" && !v.is_null() {
950 if let Some(elem_type) = &arg.element_type {
952 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
953 parts.push(emit_ruby_batch_item_array(v, elem_type));
954 continue;
955 }
956 }
957 if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
959 let kwargs: Vec<String> = obj
960 .iter()
961 .map(|(k, vv)| {
962 let snake_key = k.to_snake_case();
963 let rb_val = if enum_fields.contains_key(k) {
964 if let Some(s) = vv.as_str() {
965 let snake_val = s.to_snake_case();
966 format!("'{snake_val}'")
967 } else {
968 json_to_ruby(vv)
969 }
970 } else {
971 json_to_ruby(vv)
972 };
973 format!("{snake_key}: {rb_val}")
974 })
975 .collect();
976 if result_is_simple {
977 parts.push(format!("{{{}}}", kwargs.join(", ")));
978 } else {
979 parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
980 }
981 continue;
982 }
983 }
984 parts.push(json_to_ruby(v));
985 }
986 }
987 }
988
989 (setup_lines, parts.join(", "))
990}
991
992fn render_assertion(
993 out: &mut String,
994 assertion: &Assertion,
995 result_var: &str,
996 field_resolver: &FieldResolver,
997 result_is_simple: bool,
998 e2e_config: &E2eConfig,
999) {
1000 if result_is_simple {
1004 if let Some(f) = &assertion.field {
1005 if !f.is_empty() {
1006 match assertion.assertion_type.as_str() {
1007 "not_empty" => {
1008 out.push_str(&format!(" expect({result_var}.to_s).not_to be_empty\n"));
1009 return;
1010 }
1011 "is_empty" => {
1012 out.push_str(&format!(" expect({result_var}.to_s).to be_empty\n"));
1013 return;
1014 }
1015 "count_equals" => {
1016 if let Some(val) = &assertion.value {
1017 let rb_val = json_to_ruby(val);
1018 out.push_str(&format!(" expect({result_var}.length).to eq({rb_val})\n"));
1019 }
1020 return;
1021 }
1022 "count_min" => {
1023 if let Some(val) = &assertion.value {
1024 let rb_val = json_to_ruby(val);
1025 out.push_str(&format!(" expect({result_var}.length).to be >= {rb_val}\n"));
1026 }
1027 return;
1028 }
1029 _ => {
1030 out.push_str(&format!(
1031 " # skipped: field '{f}' not applicable for simple result type\n"
1032 ));
1033 return;
1034 }
1035 }
1036 }
1037 }
1038 }
1039 if let Some(f) = &assertion.field {
1042 match f.as_str() {
1043 "chunks_have_content" => {
1044 let pred = format!("({result_var}.chunks || []).all? {{ |c| c.content && !c.content.empty? }}");
1045 match assertion.assertion_type.as_str() {
1046 "is_true" => {
1047 out.push_str(&format!(" expect({pred}).to be(true)\n"));
1048 }
1049 "is_false" => {
1050 out.push_str(&format!(" expect({pred}).to be(false)\n"));
1051 }
1052 _ => {
1053 out.push_str(&format!(
1054 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
1055 ));
1056 }
1057 }
1058 return;
1059 }
1060 "chunks_have_embeddings" => {
1061 let pred =
1062 format!("({result_var}.chunks || []).all? {{ |c| !c.embedding.nil? && !c.embedding.empty? }}");
1063 match assertion.assertion_type.as_str() {
1064 "is_true" => {
1065 out.push_str(&format!(" expect({pred}).to be(true)\n"));
1066 }
1067 "is_false" => {
1068 out.push_str(&format!(" expect({pred}).to be(false)\n"));
1069 }
1070 _ => {
1071 out.push_str(&format!(
1072 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
1073 ));
1074 }
1075 }
1076 return;
1077 }
1078 "embeddings" => {
1082 match assertion.assertion_type.as_str() {
1083 "count_equals" => {
1084 if let Some(val) = &assertion.value {
1085 let rb_val = json_to_ruby(val);
1086 out.push_str(&format!(" expect({result_var}.length).to eq({rb_val})\n"));
1087 }
1088 }
1089 "count_min" => {
1090 if let Some(val) = &assertion.value {
1091 let rb_val = json_to_ruby(val);
1092 out.push_str(&format!(" expect({result_var}.length).to be >= {rb_val}\n"));
1093 }
1094 }
1095 "not_empty" => {
1096 out.push_str(&format!(" expect({result_var}).not_to be_empty\n"));
1097 }
1098 "is_empty" => {
1099 out.push_str(&format!(" expect({result_var}).to be_empty\n"));
1100 }
1101 _ => {
1102 out.push_str(" # skipped: unsupported assertion type on synthetic field 'embeddings'\n");
1103 }
1104 }
1105 return;
1106 }
1107 "embedding_dimensions" => {
1108 let expr = format!("({result_var}.empty? ? 0 : {result_var}[0].length)");
1109 match assertion.assertion_type.as_str() {
1110 "equals" => {
1111 if let Some(val) = &assertion.value {
1112 let rb_val = json_to_ruby(val);
1113 out.push_str(&format!(" expect({expr}).to eq({rb_val})\n"));
1114 }
1115 }
1116 "greater_than" => {
1117 if let Some(val) = &assertion.value {
1118 let rb_val = json_to_ruby(val);
1119 out.push_str(&format!(" expect({expr}).to be > {rb_val}\n"));
1120 }
1121 }
1122 _ => {
1123 out.push_str(
1124 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n",
1125 );
1126 }
1127 }
1128 return;
1129 }
1130 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1131 let pred = match f.as_str() {
1132 "embeddings_valid" => {
1133 format!("{result_var}.all? {{ |e| !e.empty? }}")
1134 }
1135 "embeddings_finite" => {
1136 format!("{result_var}.all? {{ |e| e.all? {{ |v| v.finite? }} }}")
1137 }
1138 "embeddings_non_zero" => {
1139 format!("{result_var}.all? {{ |e| e.any? {{ |v| v != 0.0 }} }}")
1140 }
1141 "embeddings_normalized" => {
1142 format!("{result_var}.all? {{ |e| n = e.sum {{ |v| v * v }}; (n - 1.0).abs < 1e-3 }}")
1143 }
1144 _ => unreachable!(),
1145 };
1146 match assertion.assertion_type.as_str() {
1147 "is_true" => {
1148 out.push_str(&format!(" expect({pred}).to be(true)\n"));
1149 }
1150 "is_false" => {
1151 out.push_str(&format!(" expect({pred}).to be(false)\n"));
1152 }
1153 _ => {
1154 out.push_str(&format!(
1155 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
1156 ));
1157 }
1158 }
1159 return;
1160 }
1161 "keywords" | "keywords_count" => {
1164 out.push_str(&format!(
1165 " # skipped: field '{f}' not available on Ruby ExtractionResult\n"
1166 ));
1167 return;
1168 }
1169 _ => {}
1170 }
1171 }
1172
1173 if let Some(f) = &assertion.field {
1175 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1176 out.push_str(&format!(" # skipped: field '{f}' not available on result type\n"));
1177 return;
1178 }
1179 }
1180
1181 if result_is_simple {
1183 if let Some(f) = &assertion.field {
1184 let f_lower = f.to_lowercase();
1185 if !f.is_empty()
1186 && f_lower != "content"
1187 && (f_lower.starts_with("metadata")
1188 || f_lower.starts_with("document")
1189 || f_lower.starts_with("structure"))
1190 {
1191 return;
1192 }
1193 }
1194 }
1195
1196 let field_expr = match &assertion.field {
1200 Some(f) if !f.is_empty() && (!result_is_simple || !f.eq_ignore_ascii_case("content")) => {
1201 field_resolver.accessor(f, "ruby", result_var)
1202 }
1203 _ => result_var.to_string(),
1204 };
1205
1206 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1210 let resolved = field_resolver.resolve(f);
1211 e2e_config.fields_enum.contains(f) || e2e_config.fields_enum.contains(resolved)
1212 });
1213 let stripped_field_expr = if result_is_simple {
1214 format!("{field_expr}.to_s.strip")
1215 } else if field_is_enum {
1216 format!("{field_expr}.to_s")
1217 } else {
1218 field_expr.clone()
1219 };
1220
1221 let field_is_array = assertion
1224 .field
1225 .as_deref()
1226 .filter(|f| !f.is_empty())
1227 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1228
1229 match assertion.assertion_type.as_str() {
1230 "equals" => {
1231 if let Some(expected) = &assertion.value {
1232 let is_boolean_val = expected.as_bool().is_some();
1233 let bool_val = expected
1234 .as_bool()
1235 .map(|b| if b { "true" } else { "false" })
1236 .unwrap_or("");
1237 let rb_val = json_to_ruby(expected);
1238
1239 let rendered = crate::template_env::render(
1240 "ruby/assertion.jinja",
1241 minijinja::context! {
1242 assertion_type => "equals",
1243 stripped_field_expr => stripped_field_expr.clone(),
1244 is_boolean_val => is_boolean_val,
1245 bool_val => bool_val,
1246 expected_val => rb_val,
1247 },
1248 );
1249 out.push_str(&rendered);
1250 }
1251 }
1252 "contains" => {
1253 if let Some(expected) = &assertion.value {
1254 let rb_val = json_to_ruby(expected);
1255 let rendered = crate::template_env::render(
1256 "ruby/assertion.jinja",
1257 minijinja::context! {
1258 assertion_type => "contains",
1259 field_expr => field_expr.clone(),
1260 field_is_array => field_is_array && expected.is_string(),
1261 expected_val => rb_val,
1262 },
1263 );
1264 out.push_str(&rendered);
1265 }
1266 }
1267 "contains_all" => {
1268 if let Some(values) = &assertion.values {
1269 let values_list: Vec<String> = values.iter().map(json_to_ruby).collect();
1270 let rendered = crate::template_env::render(
1271 "ruby/assertion.jinja",
1272 minijinja::context! {
1273 assertion_type => "contains_all",
1274 field_expr => field_expr.clone(),
1275 field_is_array => field_is_array,
1276 values_list => values_list,
1277 },
1278 );
1279 out.push_str(&rendered);
1280 }
1281 }
1282 "not_contains" => {
1283 if let Some(expected) = &assertion.value {
1284 let rb_val = json_to_ruby(expected);
1285 let rendered = crate::template_env::render(
1286 "ruby/assertion.jinja",
1287 minijinja::context! {
1288 assertion_type => "not_contains",
1289 field_expr => field_expr.clone(),
1290 field_is_array => field_is_array && expected.is_string(),
1291 expected_val => rb_val,
1292 },
1293 );
1294 out.push_str(&rendered);
1295 }
1296 }
1297 "not_empty" => {
1298 let rendered = crate::template_env::render(
1299 "ruby/assertion.jinja",
1300 minijinja::context! {
1301 assertion_type => "not_empty",
1302 field_expr => field_expr.clone(),
1303 },
1304 );
1305 out.push_str(&rendered);
1306 }
1307 "is_empty" => {
1308 let rendered = crate::template_env::render(
1309 "ruby/assertion.jinja",
1310 minijinja::context! {
1311 assertion_type => "is_empty",
1312 field_expr => field_expr.clone(),
1313 },
1314 );
1315 out.push_str(&rendered);
1316 }
1317 "contains_any" => {
1318 if let Some(values) = &assertion.values {
1319 let items: Vec<String> = values.iter().map(json_to_ruby).collect();
1320 let rendered = crate::template_env::render(
1321 "ruby/assertion.jinja",
1322 minijinja::context! {
1323 assertion_type => "contains_any",
1324 field_expr => field_expr.clone(),
1325 values_list => items,
1326 },
1327 );
1328 out.push_str(&rendered);
1329 }
1330 }
1331 "greater_than" => {
1332 if let Some(val) = &assertion.value {
1333 let rb_val = json_to_ruby(val);
1334 let rendered = crate::template_env::render(
1335 "ruby/assertion.jinja",
1336 minijinja::context! {
1337 assertion_type => "greater_than",
1338 field_expr => field_expr.clone(),
1339 expected_val => rb_val,
1340 },
1341 );
1342 out.push_str(&rendered);
1343 }
1344 }
1345 "less_than" => {
1346 if let Some(val) = &assertion.value {
1347 let rb_val = json_to_ruby(val);
1348 let rendered = crate::template_env::render(
1349 "ruby/assertion.jinja",
1350 minijinja::context! {
1351 assertion_type => "less_than",
1352 field_expr => field_expr.clone(),
1353 expected_val => rb_val,
1354 },
1355 );
1356 out.push_str(&rendered);
1357 }
1358 }
1359 "greater_than_or_equal" => {
1360 if let Some(val) = &assertion.value {
1361 let rb_val = json_to_ruby(val);
1362 let rendered = crate::template_env::render(
1363 "ruby/assertion.jinja",
1364 minijinja::context! {
1365 assertion_type => "greater_than_or_equal",
1366 field_expr => field_expr.clone(),
1367 expected_val => rb_val,
1368 },
1369 );
1370 out.push_str(&rendered);
1371 }
1372 }
1373 "less_than_or_equal" => {
1374 if let Some(val) = &assertion.value {
1375 let rb_val = json_to_ruby(val);
1376 let rendered = crate::template_env::render(
1377 "ruby/assertion.jinja",
1378 minijinja::context! {
1379 assertion_type => "less_than_or_equal",
1380 field_expr => field_expr.clone(),
1381 expected_val => rb_val,
1382 },
1383 );
1384 out.push_str(&rendered);
1385 }
1386 }
1387 "starts_with" => {
1388 if let Some(expected) = &assertion.value {
1389 let rb_val = json_to_ruby(expected);
1390 let rendered = crate::template_env::render(
1391 "ruby/assertion.jinja",
1392 minijinja::context! {
1393 assertion_type => "starts_with",
1394 field_expr => field_expr.clone(),
1395 expected_val => rb_val,
1396 },
1397 );
1398 out.push_str(&rendered);
1399 }
1400 }
1401 "ends_with" => {
1402 if let Some(expected) = &assertion.value {
1403 let rb_val = json_to_ruby(expected);
1404 let rendered = crate::template_env::render(
1405 "ruby/assertion.jinja",
1406 minijinja::context! {
1407 assertion_type => "ends_with",
1408 field_expr => field_expr.clone(),
1409 expected_val => rb_val,
1410 },
1411 );
1412 out.push_str(&rendered);
1413 }
1414 }
1415 "min_length" | "max_length" | "count_min" | "count_equals" => {
1416 if let Some(val) = &assertion.value {
1417 if let Some(n) = val.as_u64() {
1418 let rendered = crate::template_env::render(
1419 "ruby/assertion.jinja",
1420 minijinja::context! {
1421 assertion_type => assertion.assertion_type.as_str(),
1422 field_expr => field_expr.clone(),
1423 check_n => n,
1424 },
1425 );
1426 out.push_str(&rendered);
1427 }
1428 }
1429 }
1430 "is_true" => {
1431 let rendered = crate::template_env::render(
1432 "ruby/assertion.jinja",
1433 minijinja::context! {
1434 assertion_type => "is_true",
1435 field_expr => field_expr.clone(),
1436 },
1437 );
1438 out.push_str(&rendered);
1439 }
1440 "is_false" => {
1441 let rendered = crate::template_env::render(
1442 "ruby/assertion.jinja",
1443 minijinja::context! {
1444 assertion_type => "is_false",
1445 field_expr => field_expr.clone(),
1446 },
1447 );
1448 out.push_str(&rendered);
1449 }
1450 "method_result" => {
1451 if let Some(method_name) = &assertion.method {
1452 let lang = "ruby";
1454 let call = &e2e_config.call;
1455 let overrides = call.overrides.get(lang);
1456 let module_path = overrides
1457 .and_then(|o| o.module.as_ref())
1458 .cloned()
1459 .unwrap_or_else(|| call.module.clone());
1460 let call_receiver = ruby_module_name(&module_path);
1461
1462 let call_expr =
1463 build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
1464 let check = assertion.check.as_deref().unwrap_or("is_true");
1465
1466 let (check_val_str, is_boolean_check, bool_check_val, check_n_val) = match check {
1467 "equals" => {
1468 if let Some(val) = &assertion.value {
1469 let is_bool = val.as_bool().is_some();
1470 let bool_str = val.as_bool().map(|b| if b { "true" } else { "false" }).unwrap_or("");
1471 let rb_val = json_to_ruby(val);
1472 (rb_val, is_bool, bool_str.to_string(), 0)
1473 } else {
1474 (String::new(), false, String::new(), 0)
1475 }
1476 }
1477 "greater_than_or_equal" => {
1478 if let Some(val) = &assertion.value {
1479 (json_to_ruby(val), false, String::new(), 0)
1480 } else {
1481 (String::new(), false, String::new(), 0)
1482 }
1483 }
1484 "count_min" => {
1485 if let Some(val) = &assertion.value {
1486 let n = val.as_u64().unwrap_or(0);
1487 (String::new(), false, String::new(), n)
1488 } else {
1489 (String::new(), false, String::new(), 0)
1490 }
1491 }
1492 "contains" => {
1493 if let Some(val) = &assertion.value {
1494 (json_to_ruby(val), false, String::new(), 0)
1495 } else {
1496 (String::new(), false, String::new(), 0)
1497 }
1498 }
1499 _ => (String::new(), false, String::new(), 0),
1500 };
1501
1502 let rendered = crate::template_env::render(
1503 "ruby/assertion.jinja",
1504 minijinja::context! {
1505 assertion_type => "method_result",
1506 call_expr => call_expr,
1507 check => check,
1508 check_val => check_val_str,
1509 is_boolean_check => is_boolean_check,
1510 bool_check_val => bool_check_val,
1511 check_n => check_n_val,
1512 },
1513 );
1514 out.push_str(&rendered);
1515 } else {
1516 panic!("Ruby e2e generator: method_result assertion missing 'method' field");
1517 }
1518 }
1519 "matches_regex" => {
1520 if let Some(expected) = &assertion.value {
1521 let rb_val = json_to_ruby(expected);
1522 let rendered = crate::template_env::render(
1523 "ruby/assertion.jinja",
1524 minijinja::context! {
1525 assertion_type => "matches_regex",
1526 field_expr => field_expr.clone(),
1527 expected_val => rb_val,
1528 },
1529 );
1530 out.push_str(&rendered);
1531 }
1532 }
1533 "not_error" => {
1534 }
1536 "error" => {
1537 }
1539 other => {
1540 panic!("Ruby e2e generator: unsupported assertion type: {other}");
1541 }
1542 }
1543}
1544
1545fn build_ruby_method_call(
1548 call_receiver: &str,
1549 result_var: &str,
1550 method_name: &str,
1551 args: Option<&serde_json::Value>,
1552) -> String {
1553 match method_name {
1554 "root_child_count" => format!("{result_var}.root_node.child_count"),
1555 "root_node_type" => format!("{result_var}.root_node.type"),
1556 "named_children_count" => format!("{result_var}.root_node.named_child_count"),
1557 "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
1558 "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
1559 "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
1560 "contains_node_type" => {
1561 let node_type = args
1562 .and_then(|a| a.get("node_type"))
1563 .and_then(|v| v.as_str())
1564 .unwrap_or("");
1565 format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
1566 }
1567 "find_nodes_by_type" => {
1568 let node_type = args
1569 .and_then(|a| a.get("node_type"))
1570 .and_then(|v| v.as_str())
1571 .unwrap_or("");
1572 format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
1573 }
1574 "run_query" => {
1575 let query_source = args
1576 .and_then(|a| a.get("query_source"))
1577 .and_then(|v| v.as_str())
1578 .unwrap_or("");
1579 let language = args
1580 .and_then(|a| a.get("language"))
1581 .and_then(|v| v.as_str())
1582 .unwrap_or("");
1583 format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1584 }
1585 _ => format!("{result_var}.{method_name}"),
1586 }
1587}
1588
1589fn ruby_module_name(module_path: &str) -> String {
1592 use heck::ToUpperCamelCase;
1593 module_path.to_upper_camel_case()
1594}
1595
1596fn json_to_ruby(value: &serde_json::Value) -> String {
1598 match value {
1599 serde_json::Value::String(s) => ruby_string_literal(s),
1600 serde_json::Value::Bool(true) => "true".to_string(),
1601 serde_json::Value::Bool(false) => "false".to_string(),
1602 serde_json::Value::Number(n) => n.to_string(),
1603 serde_json::Value::Null => "nil".to_string(),
1604 serde_json::Value::Array(arr) => {
1605 let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
1606 format!("[{}]", items.join(", "))
1607 }
1608 serde_json::Value::Object(map) => {
1609 let items: Vec<String> = map
1610 .iter()
1611 .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
1612 .collect();
1613 format!("{{ {} }}", items.join(", "))
1614 }
1615 }
1616}
1617
1618fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1624 setup_lines.push("visitor = Class.new do".to_string());
1625 for (method_name, action) in &visitor_spec.callbacks {
1626 emit_ruby_visitor_method(setup_lines, method_name, action);
1627 }
1628 setup_lines.push("end.new".to_string());
1629 "visitor".to_string()
1630}
1631
1632fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1634 let params = match method_name {
1635 "visit_link" => "ctx, href, text, title",
1636 "visit_image" => "ctx, src, alt, title",
1637 "visit_heading" => "ctx, level, text, id",
1638 "visit_code_block" => "ctx, lang, code",
1639 "visit_code_inline"
1640 | "visit_strong"
1641 | "visit_emphasis"
1642 | "visit_strikethrough"
1643 | "visit_underline"
1644 | "visit_subscript"
1645 | "visit_superscript"
1646 | "visit_mark"
1647 | "visit_button"
1648 | "visit_summary"
1649 | "visit_figcaption"
1650 | "visit_definition_term"
1651 | "visit_definition_description" => "ctx, text",
1652 "visit_text" => "ctx, text",
1653 "visit_list_item" => "ctx, ordered, marker, text",
1654 "visit_blockquote" => "ctx, content, depth",
1655 "visit_table_row" => "ctx, cells, is_header",
1656 "visit_custom_element" => "ctx, tag_name, html",
1657 "visit_form" => "ctx, action_url, method",
1658 "visit_input" => "ctx, input_type, name, value",
1659 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1660 "visit_details" => "ctx, is_open",
1661 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1662 "visit_list_start" => "ctx, ordered",
1663 "visit_list_end" => "ctx, ordered, output",
1664 _ => "ctx",
1665 };
1666
1667 let (action_type, action_value) = match action {
1669 CallbackAction::Skip => ("skip", String::new()),
1670 CallbackAction::Continue => ("continue", String::new()),
1671 CallbackAction::PreserveHtml => ("preserve_html", String::new()),
1672 CallbackAction::Custom { output } => {
1673 let escaped = ruby_string_literal(output);
1674 ("custom", escaped)
1675 }
1676 CallbackAction::CustomTemplate { template } => {
1677 let interpolated = ruby_template_to_interpolation(template);
1678 ("custom", format!("\"{interpolated}\""))
1679 }
1680 };
1681
1682 let rendered = crate::template_env::render(
1683 "ruby/visitor_method.jinja",
1684 minijinja::context! {
1685 method_name => method_name,
1686 params => params,
1687 action_type => action_type,
1688 action_value => action_value,
1689 },
1690 );
1691 for line in rendered.lines() {
1692 setup_lines.push(line.to_string());
1693 }
1694}
1695
1696fn is_file_path(s: &str) -> bool {
1701 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1702 return false;
1703 }
1704
1705 let first = s.chars().next().unwrap_or('\0');
1706 if first.is_ascii_alphanumeric() || first == '_' {
1707 if let Some(slash_pos) = s.find('/') {
1708 if slash_pos > 0 {
1709 let after_slash = &s[slash_pos + 1..];
1710 if after_slash.contains('.') && !after_slash.is_empty() {
1711 return true;
1712 }
1713 }
1714 }
1715 }
1716
1717 false
1718}
1719
1720fn is_base64(s: &str) -> bool {
1723 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1724 return false;
1725 }
1726
1727 if is_file_path(s) {
1728 return false;
1729 }
1730
1731 true
1732}