1use crate::config::E2eConfig;
7use crate::escape::{ruby_string_literal, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{
10 Assertion, CallbackAction, Fixture, FixtureGroup, HttpExpectedResponse, HttpFixture, HttpRequest,
11};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::AlefConfig;
14use alef_core::hash::{self, CommentStyle};
15use alef_core::template_versions as tv;
16use anyhow::Result;
17use heck::ToSnakeCase;
18use std::collections::HashMap;
19use std::fmt::Write as FmtWrite;
20use std::path::PathBuf;
21
22use super::E2eCodegen;
23
24pub struct RubyCodegen;
26
27impl E2eCodegen for RubyCodegen {
28 fn generate(
29 &self,
30 groups: &[FixtureGroup],
31 e2e_config: &E2eConfig,
32 alef_config: &AlefConfig,
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 = 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(|| alef_config.crate_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| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
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 );
124 let has_any_output = active.iter().any(|f| {
126 if f.is_http_test() {
128 return true;
129 }
130 let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
131 expects_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
132 });
133 if !has_any_output {
134 continue;
135 }
136
137 let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
138 let field_resolver = FieldResolver::new(
139 &e2e_config.fields,
140 &e2e_config.fields_optional,
141 &e2e_config.result_fields,
142 &e2e_config.fields_array,
143 );
144 let content = render_spec_file(
145 &group.category,
146 &active,
147 &module_path,
148 class_name.as_deref(),
149 &gem_name,
150 &field_resolver,
151 options_type.as_deref(),
152 enum_fields,
153 result_is_simple,
154 e2e_config,
155 has_file_fixtures || has_http_fixtures,
156 );
157 files.push(GeneratedFile {
158 path: spec_base.join(filename),
159 content,
160 generated_header: true,
161 });
162 }
163
164 Ok(files)
165 }
166
167 fn language_name(&self) -> &'static str {
168 "ruby"
169 }
170}
171
172fn render_gemfile(
177 gem_name: &str,
178 gem_path: &str,
179 gem_version: &str,
180 dep_mode: crate::config::DependencyMode,
181) -> String {
182 let gem_line = match dep_mode {
183 crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
184 crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
185 };
186 format!(
187 "# frozen_string_literal: true\n\
188 \n\
189 source 'https://rubygems.org'\n\
190 \n\
191 {gem_line}\n\
192 gem 'rspec', '{rspec}'\n\
193 gem 'rubocop', '{rubocop}'\n\
194 gem 'rubocop-rspec', '{rubocop_rspec}'\n\
195 gem 'faraday', '{faraday}'\n",
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
203fn render_spec_helper(has_file_fixtures: bool, has_http_fixtures: bool) -> String {
204 let header = hash::header(CommentStyle::Hash);
205 let mut out = header;
206 out.push_str("# frozen_string_literal: true\n");
207
208 if has_file_fixtures {
209 out.push_str(
210 r#"
211# Change to the test_documents directory so that fixture file paths like
212# "pdf/fake_memo.pdf" resolve correctly when running rspec from e2e/ruby/.
213# spec_helper.rb lives in e2e/ruby/spec/; test_documents lives at the
214# repository root, three directories up: spec/ -> e2e/ruby/ -> e2e/ -> root.
215_test_documents = File.expand_path('../../../test_documents', __dir__)
216Dir.chdir(_test_documents) if Dir.exist?(_test_documents)
217"#,
218 );
219 }
220
221 if has_http_fixtures {
222 out.push_str(
223 r#"
224require 'open3'
225
226# Spawn the mock-server binary and set MOCK_SERVER_URL for all tests.
227RSpec.configure do |config|
228 config.before(:suite) do
229 bin = File.expand_path('../../rust/target/release/mock-server', __dir__)
230 fixtures_dir = File.expand_path('../../../fixtures', __dir__)
231 unless File.exist?(bin)
232 warn "mock-server binary not found at #{bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
233 end
234 stdin, stdout, _stderr, _wait = Open3.popen3(bin, fixtures_dir)
235 url = stdout.readline.strip.split('=', 2).last
236 ENV['MOCK_SERVER_URL'] = url
237 # Drain stdout in background.
238 Thread.new { stdout.read }
239 # Store stdin so we can close it on teardown.
240 @_mock_server_stdin = stdin
241 end
242
243 config.after(:suite) do
244 @_mock_server_stdin&.close
245 end
246end
247"#,
248 );
249 }
250
251 out
252}
253
254fn render_rubocop_yaml() -> String {
255 r#"# Generated by alef e2e — do not edit.
256AllCops:
257 NewCops: enable
258 TargetRubyVersion: 3.2
259 SuggestExtensions: false
260
261plugins:
262 - rubocop-rspec
263
264# --- Justified suppressions for generated test code ---
265
266# Generated tests are verbose by nature (setup + multiple assertions).
267Metrics/BlockLength:
268 Enabled: false
269Metrics/MethodLength:
270 Enabled: false
271Layout/LineLength:
272 Enabled: false
273
274# Generated tests use multiple assertions per example for thorough verification.
275RSpec/MultipleExpectations:
276 Enabled: false
277RSpec/ExampleLength:
278 Enabled: false
279
280# Generated tests describe categories as strings, not classes.
281RSpec/DescribeClass:
282 Enabled: false
283
284# Fixture-driven tests may produce identical assertion bodies for different inputs.
285RSpec/RepeatedExample:
286 Enabled: false
287
288# Error-handling tests use bare raise_error (exception type not known at generation time).
289RSpec/UnspecifiedException:
290 Enabled: false
291"#
292 .to_string()
293}
294
295#[allow(clippy::too_many_arguments)]
296fn render_spec_file(
297 category: &str,
298 fixtures: &[&Fixture],
299 module_path: &str,
300 class_name: Option<&str>,
301 gem_name: &str,
302 field_resolver: &FieldResolver,
303 options_type: Option<&str>,
304 enum_fields: &HashMap<String, String>,
305 result_is_simple: bool,
306 e2e_config: &E2eConfig,
307 needs_spec_helper: bool,
308) -> String {
309 let mut out = String::new();
310 out.push_str(&hash::header(CommentStyle::Hash));
311 let _ = writeln!(out, "# frozen_string_literal: true");
312 let _ = writeln!(out);
313
314 let require_name = if module_path.is_empty() { gem_name } else { module_path };
316 let _ = writeln!(out, "require '{}'", require_name.replace('-', "_"));
317 let _ = writeln!(out, "require 'json'");
318
319 let has_http = fixtures.iter().any(|f| f.is_http_test());
320 if needs_spec_helper || has_http {
321 let _ = writeln!(out, "require_relative 'spec_helper'");
323 }
324 let _ = writeln!(out);
325
326 let call_receiver = class_name
328 .map(|s| s.to_string())
329 .unwrap_or_else(|| ruby_module_name(module_path));
330
331 let _ = writeln!(out, "RSpec.describe '{}' do", category);
332
333 if has_http {
335 let _ = writeln!(
336 out,
337 " let(:mock_server_url) {{ ENV.fetch('MOCK_SERVER_URL', 'http://localhost:8080') }}"
338 );
339 let _ = writeln!(out);
340 }
341
342 let mut first = true;
343 for fixture in fixtures {
344 if !first {
345 let _ = writeln!(out);
346 }
347 first = false;
348
349 if let Some(http) = &fixture.http {
350 render_http_example(&mut out, fixture, http);
351 } else {
352 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
355 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
356 if !expects_error && !has_usable {
357 let test_name = sanitize_ident(&fixture.id);
358 let description = fixture.description.replace('\'', "\\'");
359 let _ = writeln!(out, " it '{test_name}: {description}' do");
360 let _ = writeln!(out, " skip 'Non-HTTP fixture cannot be tested via Net::HTTP'");
361 let _ = writeln!(out, " end");
362 } else {
363 let fixture_call = e2e_config.resolve_call(fixture.call.as_deref());
365 let fixture_call_overrides = fixture_call.overrides.get("ruby");
366 let fixture_function_name = fixture_call_overrides
367 .and_then(|o| o.function.as_ref())
368 .cloned()
369 .unwrap_or_else(|| fixture_call.function.clone());
370 let fixture_result_var = &fixture_call.result_var;
371 let fixture_args = &fixture_call.args;
372 render_example(
373 &mut out,
374 fixture,
375 &fixture_function_name,
376 &call_receiver,
377 fixture_result_var,
378 fixture_args,
379 field_resolver,
380 options_type,
381 enum_fields,
382 result_is_simple,
383 e2e_config,
384 );
385 }
386 }
387 }
388
389 let _ = writeln!(out, "end");
390 out
391}
392
393fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
396 fixture.assertions.iter().any(|a| {
397 if a.assertion_type == "not_error" || a.assertion_type == "error" {
399 return false;
400 }
401 if let Some(f) = &a.field {
403 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
404 return false;
405 }
406 if result_is_simple {
408 let f_lower = f.to_lowercase();
409 if !f.is_empty()
410 && f_lower != "content"
411 && (f_lower.starts_with("metadata")
412 || f_lower.starts_with("document")
413 || f_lower.starts_with("structure"))
414 {
415 return false;
416 }
417 }
418 }
419 true
420 })
421}
422
423fn render_http_example(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
429 let description = fixture.description.replace('\'', "\\'");
430 let method = http.request.method.to_uppercase();
431 let path = &http.request.path;
432 let fixture_id = &fixture.id;
433 let status = http.expected_response.status_code;
434
435 let _ = writeln!(out, " describe '{method} {path}' do");
436
437 if status == 101 {
439 let _ = writeln!(out, " it '{}' do", description);
440 let _ = writeln!(
441 out,
442 " skip 'HTTP 101 WebSocket upgrade cannot be tested via Net::HTTP'"
443 );
444 let _ = writeln!(out, " end");
445 let _ = writeln!(out, " end");
446 return;
447 }
448
449 let _ = writeln!(out, " it '{}' do", description);
450
451 render_ruby_http_request_mock(out, &http.request, fixture_id);
453
454 let _ = writeln!(out, " expect(response.code.to_i).to eq({status})");
456
457 render_ruby_body_assertions(out, &http.expected_response);
459
460 render_ruby_header_assertions(out, &http.expected_response);
462
463 let _ = writeln!(out, " end");
464 let _ = writeln!(out, " end");
465}
466
467fn http_method_class(method: &str) -> String {
470 let mut chars = method.chars();
471 match chars.next() {
472 None => String::new(),
473 Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
474 }
475}
476
477fn render_ruby_http_request_mock(out: &mut String, req: &HttpRequest, fixture_id: &str) {
479 let method = req.method.to_uppercase();
480 let method_class = http_method_class(&method);
481 let _ = writeln!(out, " require 'net/http'");
482 let _ = writeln!(out, " require 'uri'");
483 let _ = writeln!(out, " require 'json'");
484 let _ = writeln!(
485 out,
486 " _uri = URI.parse(\"#{{mock_server_url}}/fixtures/{fixture_id}\")"
487 );
488 let _ = writeln!(out, " _http = Net::HTTP.new(_uri.host, _uri.port)");
489 let _ = writeln!(out, " _http.use_ssl = _uri.scheme == 'https'");
490 let _ = writeln!(out, " _req = Net::HTTP::{method_class}.new(_uri.request_uri)");
492
493 let has_body = req
494 .body
495 .as_ref()
496 .is_some_and(|b| !matches!(b, serde_json::Value::String(s) if s.is_empty()));
497 if has_body {
498 let ruby_body = json_to_ruby(req.body.as_ref().unwrap());
499 let _ = writeln!(out, " _req.body = {ruby_body}.to_json");
500 let _ = writeln!(out, " _req['Content-Type'] = 'application/json'");
501 }
502
503 for (k, v) in &req.headers {
504 if has_body && k.to_lowercase() == "content-type" {
506 continue;
507 }
508 let rk = ruby_string_literal(k);
509 let rv = ruby_string_literal(v);
510 let _ = writeln!(out, " _req[{rk}] = {rv}");
511 }
512
513 let _ = writeln!(out, " response = _http.request(_req)");
514}
515
516fn render_ruby_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
518 if let Some(body) = &expected.body {
519 match body {
520 serde_json::Value::String(s) if s.is_empty() => {}
522 serde_json::Value::Null => {}
524 serde_json::Value::String(s) => {
526 let ruby_val = ruby_string_literal(s);
527 let _ = writeln!(out, " expect(response.body).to eq({ruby_val})");
528 }
529 _ => {
530 let ruby_val = json_to_ruby(body);
531 let _ = writeln!(
533 out,
534 " _body = response.body && !response.body.empty? ? JSON.parse(response.body) : nil"
535 );
536 let _ = writeln!(out, " expect(_body).to eq({ruby_val})");
537 }
538 }
539 }
540 if let Some(partial) = &expected.body_partial {
541 if let Some(obj) = partial.as_object() {
542 let _ = writeln!(out, " _body = JSON.parse(response.body)");
543 for (key, val) in obj {
544 let ruby_key = ruby_string_literal(key);
545 let ruby_val = json_to_ruby(val);
546 let _ = writeln!(out, " expect(_body[{ruby_key}]).to eq({ruby_val})");
547 }
548 }
549 }
550 if let Some(errors) = &expected.validation_errors {
551 if expected.body.is_none() {
552 for err in errors {
554 let msg_lit = ruby_string_literal(&err.msg);
555 let _ = writeln!(out, " _body = JSON.parse(response.body)");
556 let _ = writeln!(out, " _errors = _body['errors'] || []");
557 let _ = writeln!(
558 out,
559 " expect(_errors.map {{ |e| e['msg'] }}).to include({msg_lit})"
560 );
561 }
562 }
563 }
564}
565
566fn render_ruby_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
573 for (name, value) in &expected.headers {
574 let header_key = name.to_lowercase();
575 if header_key == "content-encoding" {
578 continue;
579 }
580 let header_expr = format!("response[{}]", ruby_string_literal(&header_key));
582 match value.as_str() {
583 "<<present>>" => {
584 let _ = writeln!(out, " expect({header_expr}).not_to be_nil");
585 }
586 "<<absent>>" => {
587 let _ = writeln!(out, " expect({header_expr}).to be_nil");
588 }
589 "<<uuid>>" => {
590 let _ = writeln!(
591 out,
592 " 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)"
593 );
594 }
595 literal => {
596 let ruby_val = ruby_string_literal(literal);
597 let _ = writeln!(out, " expect({header_expr}).to eq({ruby_val})");
598 }
599 }
600 }
601}
602
603#[allow(clippy::too_many_arguments)]
608fn render_example(
609 out: &mut String,
610 fixture: &Fixture,
611 function_name: &str,
612 call_receiver: &str,
613 result_var: &str,
614 args: &[crate::config::ArgMapping],
615 field_resolver: &FieldResolver,
616 options_type: Option<&str>,
617 enum_fields: &HashMap<String, String>,
618 result_is_simple: bool,
619 e2e_config: &E2eConfig,
620) {
621 let test_name = sanitize_ident(&fixture.id);
622 let description = fixture.description.replace('\'', "\\'");
623 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
624
625 let (mut setup_lines, args_str) = build_args_and_setup(
626 &fixture.input,
627 args,
628 call_receiver,
629 options_type,
630 enum_fields,
631 result_is_simple,
632 &fixture.id,
633 );
634
635 let mut visitor_arg = String::new();
637 if let Some(visitor_spec) = &fixture.visitor {
638 visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
639 }
640
641 let final_args = if visitor_arg.is_empty() {
642 args_str
643 } else if args_str.is_empty() {
644 visitor_arg
645 } else {
646 format!("{args_str}, {visitor_arg}")
647 };
648
649 let call_expr = format!("{call_receiver}.{function_name}({final_args})");
650
651 let _ = writeln!(out, " it '{test_name}: {description}' do");
652
653 for line in &setup_lines {
654 let _ = writeln!(out, " {line}");
655 }
656
657 if expects_error {
658 let _ = writeln!(out, " expect {{ {call_expr} }}.to raise_error");
659 let _ = writeln!(out, " end");
660 return;
661 }
662
663 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
665 let _ = writeln!(out, " {result_var} = {call_expr}");
666
667 for assertion in &fixture.assertions {
668 render_assertion(out, assertion, result_var, field_resolver, result_is_simple, e2e_config);
669 }
670
671 if !has_usable {
675 let _ = writeln!(out, " expect({result_var}).not_to be_nil");
676 }
677
678 let _ = writeln!(out, " end");
679}
680
681fn build_args_and_setup(
685 input: &serde_json::Value,
686 args: &[crate::config::ArgMapping],
687 call_receiver: &str,
688 options_type: Option<&str>,
689 enum_fields: &HashMap<String, String>,
690 result_is_simple: bool,
691 fixture_id: &str,
692) -> (Vec<String>, String) {
693 if args.is_empty() {
694 return (Vec::new(), json_to_ruby(input));
695 }
696
697 let mut setup_lines: Vec<String> = Vec::new();
698 let mut parts: Vec<String> = Vec::new();
699 let mut skipped_optional_count: usize = 0;
702
703 for arg in args {
704 if arg.arg_type == "mock_url" {
705 for _ in 0..skipped_optional_count {
707 parts.push("nil".to_string());
708 }
709 skipped_optional_count = 0;
710 setup_lines.push(format!(
711 "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
712 arg.name,
713 ));
714 parts.push(arg.name.clone());
715 continue;
716 }
717
718 if arg.arg_type == "handle" {
719 for _ in 0..skipped_optional_count {
721 parts.push("nil".to_string());
722 }
723 skipped_optional_count = 0;
724 let constructor_name = format!("create_{}", arg.name.to_snake_case());
726 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
727 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
728 if config_value.is_null()
729 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
730 {
731 setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
732 } else {
733 let literal = json_to_ruby(config_value);
734 let name = &arg.name;
735 setup_lines.push(format!("{name}_config = {literal}"));
736 setup_lines.push(format!(
737 "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
738 arg.name,
739 name = name,
740 ));
741 }
742 parts.push(arg.name.clone());
743 continue;
744 }
745
746 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
747 let val = input.get(field);
748 match val {
749 None | Some(serde_json::Value::Null) if arg.optional => {
750 skipped_optional_count += 1;
752 continue;
753 }
754 None | Some(serde_json::Value::Null) => {
755 for _ in 0..skipped_optional_count {
757 parts.push("nil".to_string());
758 }
759 skipped_optional_count = 0;
760 let default_val = match arg.arg_type.as_str() {
761 "string" => "''".to_string(),
762 "int" | "integer" => "0".to_string(),
763 "float" | "number" => "0.0".to_string(),
764 "bool" | "boolean" => "false".to_string(),
765 _ => "nil".to_string(),
766 };
767 parts.push(default_val);
768 }
769 Some(v) => {
770 for _ in 0..skipped_optional_count {
772 parts.push("nil".to_string());
773 }
774 skipped_optional_count = 0;
775 if arg.arg_type == "json_object" && !v.is_null() {
778 if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
779 let kwargs: Vec<String> = obj
780 .iter()
781 .map(|(k, vv)| {
782 let snake_key = k.to_snake_case();
783 let rb_val = if enum_fields.contains_key(k) {
784 if let Some(s) = vv.as_str() {
785 let snake_val = s.to_snake_case();
786 format!("'{snake_val}'")
787 } else {
788 json_to_ruby(vv)
789 }
790 } else {
791 json_to_ruby(vv)
792 };
793 format!("{snake_key}: {rb_val}")
794 })
795 .collect();
796 if result_is_simple {
797 parts.push(format!("{{{}}}", kwargs.join(", ")));
798 } else {
799 parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
800 }
801 continue;
802 }
803 }
804 parts.push(json_to_ruby(v));
805 }
806 }
807 }
808
809 (setup_lines, parts.join(", "))
810}
811
812fn render_assertion(
813 out: &mut String,
814 assertion: &Assertion,
815 result_var: &str,
816 field_resolver: &FieldResolver,
817 result_is_simple: bool,
818 e2e_config: &E2eConfig,
819) {
820 if let Some(f) = &assertion.field {
823 match f.as_str() {
824 "chunks_have_content" => {
825 let pred = format!("({result_var}.chunks || []).all? {{ |c| c.content && !c.content.empty? }}");
826 match assertion.assertion_type.as_str() {
827 "is_true" => {
828 let _ = writeln!(out, " expect({pred}).to be(true)");
829 }
830 "is_false" => {
831 let _ = writeln!(out, " expect({pred}).to be(false)");
832 }
833 _ => {
834 let _ = writeln!(
835 out,
836 " # skipped: unsupported assertion type on synthetic field '{f}'"
837 );
838 }
839 }
840 return;
841 }
842 "chunks_have_embeddings" => {
843 let pred =
844 format!("({result_var}.chunks || []).all? {{ |c| !c.embedding.nil? && !c.embedding.empty? }}");
845 match assertion.assertion_type.as_str() {
846 "is_true" => {
847 let _ = writeln!(out, " expect({pred}).to be(true)");
848 }
849 "is_false" => {
850 let _ = writeln!(out, " expect({pred}).to be(false)");
851 }
852 _ => {
853 let _ = writeln!(
854 out,
855 " # skipped: unsupported assertion type on synthetic field '{f}'"
856 );
857 }
858 }
859 return;
860 }
861 "embeddings" => {
865 match assertion.assertion_type.as_str() {
866 "count_equals" => {
867 if let Some(val) = &assertion.value {
868 let rb_val = json_to_ruby(val);
869 let _ = writeln!(out, " expect({result_var}.length).to eq({rb_val})");
870 }
871 }
872 "count_min" => {
873 if let Some(val) = &assertion.value {
874 let rb_val = json_to_ruby(val);
875 let _ = writeln!(out, " expect({result_var}.length).to be >= {rb_val}");
876 }
877 }
878 "not_empty" => {
879 let _ = writeln!(out, " expect({result_var}).not_to be_empty");
880 }
881 "is_empty" => {
882 let _ = writeln!(out, " expect({result_var}).to be_empty");
883 }
884 _ => {
885 let _ = writeln!(
886 out,
887 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
888 );
889 }
890 }
891 return;
892 }
893 "embedding_dimensions" => {
894 let expr = format!("({result_var}.empty? ? 0 : {result_var}[0].length)");
895 match assertion.assertion_type.as_str() {
896 "equals" => {
897 if let Some(val) = &assertion.value {
898 let rb_val = json_to_ruby(val);
899 let _ = writeln!(out, " expect({expr}).to eq({rb_val})");
900 }
901 }
902 "greater_than" => {
903 if let Some(val) = &assertion.value {
904 let rb_val = json_to_ruby(val);
905 let _ = writeln!(out, " expect({expr}).to be > {rb_val}");
906 }
907 }
908 _ => {
909 let _ = writeln!(
910 out,
911 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
912 );
913 }
914 }
915 return;
916 }
917 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
918 let pred = match f.as_str() {
919 "embeddings_valid" => {
920 format!("{result_var}.all? {{ |e| !e.empty? }}")
921 }
922 "embeddings_finite" => {
923 format!("{result_var}.all? {{ |e| e.all? {{ |v| v.finite? }} }}")
924 }
925 "embeddings_non_zero" => {
926 format!("{result_var}.all? {{ |e| e.any? {{ |v| v != 0.0 }} }}")
927 }
928 "embeddings_normalized" => {
929 format!("{result_var}.all? {{ |e| n = e.sum {{ |v| v * v }}; (n - 1.0).abs < 1e-3 }}")
930 }
931 _ => unreachable!(),
932 };
933 match assertion.assertion_type.as_str() {
934 "is_true" => {
935 let _ = writeln!(out, " expect({pred}).to be(true)");
936 }
937 "is_false" => {
938 let _ = writeln!(out, " expect({pred}).to be(false)");
939 }
940 _ => {
941 let _ = writeln!(
942 out,
943 " # skipped: unsupported assertion type on synthetic field '{f}'"
944 );
945 }
946 }
947 return;
948 }
949 "keywords" | "keywords_count" => {
952 let _ = writeln!(out, " # skipped: field '{f}' not available on Ruby ExtractionResult");
953 return;
954 }
955 _ => {}
956 }
957 }
958
959 if let Some(f) = &assertion.field {
961 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
962 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
963 return;
964 }
965 }
966
967 if result_is_simple {
969 if let Some(f) = &assertion.field {
970 let f_lower = f.to_lowercase();
971 if !f.is_empty()
972 && f_lower != "content"
973 && (f_lower.starts_with("metadata")
974 || f_lower.starts_with("document")
975 || f_lower.starts_with("structure"))
976 {
977 return;
978 }
979 }
980 }
981
982 let field_expr = if result_is_simple {
983 result_var.to_string()
984 } else {
985 match &assertion.field {
986 Some(f) if !f.is_empty() => field_resolver.accessor(f, "ruby", result_var),
987 _ => result_var.to_string(),
988 }
989 };
990
991 let stripped_field_expr = if result_is_simple {
994 format!("{field_expr}.strip")
995 } else {
996 field_expr.clone()
997 };
998
999 match assertion.assertion_type.as_str() {
1000 "equals" => {
1001 if let Some(expected) = &assertion.value {
1002 if let Some(b) = expected.as_bool() {
1004 let _ = writeln!(out, " expect({stripped_field_expr}).to be({b})");
1005 } else {
1006 let rb_val = json_to_ruby(expected);
1007 let _ = writeln!(out, " expect({stripped_field_expr}).to eq({rb_val})");
1008 }
1009 }
1010 }
1011 "contains" => {
1012 if let Some(expected) = &assertion.value {
1013 let rb_val = json_to_ruby(expected);
1014 let _ = writeln!(out, " expect({field_expr}.to_s).to include({rb_val})");
1016 }
1017 }
1018 "contains_all" => {
1019 if let Some(values) = &assertion.values {
1020 for val in values {
1021 let rb_val = json_to_ruby(val);
1022 let _ = writeln!(out, " expect({field_expr}.to_s).to include({rb_val})");
1023 }
1024 }
1025 }
1026 "not_contains" => {
1027 if let Some(expected) = &assertion.value {
1028 let rb_val = json_to_ruby(expected);
1029 let _ = writeln!(out, " expect({field_expr}.to_s).not_to include({rb_val})");
1030 }
1031 }
1032 "not_empty" => {
1033 let _ = writeln!(out, " expect({field_expr}).not_to be_empty");
1034 }
1035 "is_empty" => {
1036 let _ = writeln!(out, " expect({field_expr}.nil? || {field_expr}.empty?).to be(true)");
1038 }
1039 "contains_any" => {
1040 if let Some(values) = &assertion.values {
1041 let items: Vec<String> = values.iter().map(json_to_ruby).collect();
1042 let arr_str = items.join(", ");
1043 let _ = writeln!(
1044 out,
1045 " expect([{arr_str}].any? {{ |v| {field_expr}.to_s.include?(v) }}).to be(true)"
1046 );
1047 }
1048 }
1049 "greater_than" => {
1050 if let Some(val) = &assertion.value {
1051 let rb_val = json_to_ruby(val);
1052 let _ = writeln!(out, " expect({field_expr}).to be > {rb_val}");
1053 }
1054 }
1055 "less_than" => {
1056 if let Some(val) = &assertion.value {
1057 let rb_val = json_to_ruby(val);
1058 let _ = writeln!(out, " expect({field_expr}).to be < {rb_val}");
1059 }
1060 }
1061 "greater_than_or_equal" => {
1062 if let Some(val) = &assertion.value {
1063 let rb_val = json_to_ruby(val);
1064 let _ = writeln!(out, " expect({field_expr}).to be >= {rb_val}");
1065 }
1066 }
1067 "less_than_or_equal" => {
1068 if let Some(val) = &assertion.value {
1069 let rb_val = json_to_ruby(val);
1070 let _ = writeln!(out, " expect({field_expr}).to be <= {rb_val}");
1071 }
1072 }
1073 "starts_with" => {
1074 if let Some(expected) = &assertion.value {
1075 let rb_val = json_to_ruby(expected);
1076 let _ = writeln!(out, " expect({field_expr}).to start_with({rb_val})");
1077 }
1078 }
1079 "ends_with" => {
1080 if let Some(expected) = &assertion.value {
1081 let rb_val = json_to_ruby(expected);
1082 let _ = writeln!(out, " expect({field_expr}).to end_with({rb_val})");
1083 }
1084 }
1085 "min_length" => {
1086 if let Some(val) = &assertion.value {
1087 if let Some(n) = val.as_u64() {
1088 let _ = writeln!(out, " expect({field_expr}.length).to be >= {n}");
1089 }
1090 }
1091 }
1092 "max_length" => {
1093 if let Some(val) = &assertion.value {
1094 if let Some(n) = val.as_u64() {
1095 let _ = writeln!(out, " expect({field_expr}.length).to be <= {n}");
1096 }
1097 }
1098 }
1099 "count_min" => {
1100 if let Some(val) = &assertion.value {
1101 if let Some(n) = val.as_u64() {
1102 let _ = writeln!(out, " expect({field_expr}.length).to be >= {n}");
1103 }
1104 }
1105 }
1106 "count_equals" => {
1107 if let Some(val) = &assertion.value {
1108 if let Some(n) = val.as_u64() {
1109 let _ = writeln!(out, " expect({field_expr}.length).to eq({n})");
1110 }
1111 }
1112 }
1113 "is_true" => {
1114 let _ = writeln!(out, " expect({field_expr}).to be true");
1115 }
1116 "is_false" => {
1117 let _ = writeln!(out, " expect({field_expr}).to be false");
1118 }
1119 "method_result" => {
1120 if let Some(method_name) = &assertion.method {
1121 let lang = "ruby";
1123 let call = &e2e_config.call;
1124 let overrides = call.overrides.get(lang);
1125 let module_path = overrides
1126 .and_then(|o| o.module.as_ref())
1127 .cloned()
1128 .unwrap_or_else(|| call.module.clone());
1129 let call_receiver = ruby_module_name(&module_path);
1130
1131 let call_expr =
1132 build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
1133 let check = assertion.check.as_deref().unwrap_or("is_true");
1134 match check {
1135 "equals" => {
1136 if let Some(val) = &assertion.value {
1137 if let Some(b) = val.as_bool() {
1138 let _ = writeln!(out, " expect({call_expr}).to be {b}");
1139 } else {
1140 let rb_val = json_to_ruby(val);
1141 let _ = writeln!(out, " expect({call_expr}).to eq({rb_val})");
1142 }
1143 }
1144 }
1145 "is_true" => {
1146 let _ = writeln!(out, " expect({call_expr}).to be true");
1147 }
1148 "is_false" => {
1149 let _ = writeln!(out, " expect({call_expr}).to be false");
1150 }
1151 "greater_than_or_equal" => {
1152 if let Some(val) = &assertion.value {
1153 let rb_val = json_to_ruby(val);
1154 let _ = writeln!(out, " expect({call_expr}).to be >= {rb_val}");
1155 }
1156 }
1157 "count_min" => {
1158 if let Some(val) = &assertion.value {
1159 let n = val.as_u64().unwrap_or(0);
1160 let _ = writeln!(out, " expect({call_expr}.length).to be >= {n}");
1161 }
1162 }
1163 "is_error" => {
1164 let _ = writeln!(out, " expect {{ {call_expr} }}.to raise_error");
1165 }
1166 "contains" => {
1167 if let Some(val) = &assertion.value {
1168 let rb_val = json_to_ruby(val);
1169 let _ = writeln!(out, " expect({call_expr}).to include({rb_val})");
1170 }
1171 }
1172 other_check => {
1173 panic!("Ruby e2e generator: unsupported method_result check type: {other_check}");
1174 }
1175 }
1176 } else {
1177 panic!("Ruby e2e generator: method_result assertion missing 'method' field");
1178 }
1179 }
1180 "matches_regex" => {
1181 if let Some(expected) = &assertion.value {
1182 let rb_val = json_to_ruby(expected);
1183 let _ = writeln!(out, " expect({field_expr}).to match({rb_val})");
1184 }
1185 }
1186 "not_error" => {
1187 }
1189 "error" => {
1190 }
1192 other => {
1193 panic!("Ruby e2e generator: unsupported assertion type: {other}");
1194 }
1195 }
1196}
1197
1198fn build_ruby_method_call(
1201 call_receiver: &str,
1202 result_var: &str,
1203 method_name: &str,
1204 args: Option<&serde_json::Value>,
1205) -> String {
1206 match method_name {
1207 "root_child_count" => format!("{result_var}.root_node.child_count"),
1208 "root_node_type" => format!("{result_var}.root_node.type"),
1209 "named_children_count" => format!("{result_var}.root_node.named_child_count"),
1210 "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
1211 "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
1212 "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
1213 "contains_node_type" => {
1214 let node_type = args
1215 .and_then(|a| a.get("node_type"))
1216 .and_then(|v| v.as_str())
1217 .unwrap_or("");
1218 format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
1219 }
1220 "find_nodes_by_type" => {
1221 let node_type = args
1222 .and_then(|a| a.get("node_type"))
1223 .and_then(|v| v.as_str())
1224 .unwrap_or("");
1225 format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
1226 }
1227 "run_query" => {
1228 let query_source = args
1229 .and_then(|a| a.get("query_source"))
1230 .and_then(|v| v.as_str())
1231 .unwrap_or("");
1232 let language = args
1233 .and_then(|a| a.get("language"))
1234 .and_then(|v| v.as_str())
1235 .unwrap_or("");
1236 format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1237 }
1238 _ => format!("{result_var}.{method_name}"),
1239 }
1240}
1241
1242fn ruby_module_name(module_path: &str) -> String {
1245 use heck::ToUpperCamelCase;
1246 module_path.to_upper_camel_case()
1247}
1248
1249fn json_to_ruby(value: &serde_json::Value) -> String {
1251 match value {
1252 serde_json::Value::String(s) => ruby_string_literal(s),
1253 serde_json::Value::Bool(true) => "true".to_string(),
1254 serde_json::Value::Bool(false) => "false".to_string(),
1255 serde_json::Value::Number(n) => n.to_string(),
1256 serde_json::Value::Null => "nil".to_string(),
1257 serde_json::Value::Array(arr) => {
1258 let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
1259 format!("[{}]", items.join(", "))
1260 }
1261 serde_json::Value::Object(map) => {
1262 let items: Vec<String> = map
1263 .iter()
1264 .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
1265 .collect();
1266 format!("{{ {} }}", items.join(", "))
1267 }
1268 }
1269}
1270
1271fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1277 setup_lines.push("visitor = Class.new do".to_string());
1278 for (method_name, action) in &visitor_spec.callbacks {
1279 emit_ruby_visitor_method(setup_lines, method_name, action);
1280 }
1281 setup_lines.push("end.new".to_string());
1282 "visitor".to_string()
1283}
1284
1285fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1287 let snake_method = method_name;
1288 let params = match method_name {
1289 "visit_link" => "ctx, href, text, title",
1290 "visit_image" => "ctx, src, alt, title",
1291 "visit_heading" => "ctx, level, text, id",
1292 "visit_code_block" => "ctx, lang, code",
1293 "visit_code_inline"
1294 | "visit_strong"
1295 | "visit_emphasis"
1296 | "visit_strikethrough"
1297 | "visit_underline"
1298 | "visit_subscript"
1299 | "visit_superscript"
1300 | "visit_mark"
1301 | "visit_button"
1302 | "visit_summary"
1303 | "visit_figcaption"
1304 | "visit_definition_term"
1305 | "visit_definition_description" => "ctx, text",
1306 "visit_text" => "ctx, text",
1307 "visit_list_item" => "ctx, ordered, marker, text",
1308 "visit_blockquote" => "ctx, content, depth",
1309 "visit_table_row" => "ctx, cells, is_header",
1310 "visit_custom_element" => "ctx, tag_name, html",
1311 "visit_form" => "ctx, action_url, method",
1312 "visit_input" => "ctx, input_type, name, value",
1313 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1314 "visit_details" => "ctx, is_open",
1315 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1316 "visit_list_start" => "ctx, ordered",
1317 "visit_list_end" => "ctx, ordered, output",
1318 _ => "ctx",
1319 };
1320
1321 setup_lines.push(format!(" def {snake_method}({params})"));
1322 match action {
1323 CallbackAction::Skip => {
1324 setup_lines.push(" 'skip'".to_string());
1325 }
1326 CallbackAction::Continue => {
1327 setup_lines.push(" 'continue'".to_string());
1328 }
1329 CallbackAction::PreserveHtml => {
1330 setup_lines.push(" 'preserve_html'".to_string());
1331 }
1332 CallbackAction::Custom { output } => {
1333 let escaped = ruby_string_literal(output);
1334 setup_lines.push(format!(" {{ custom: {escaped} }}"));
1335 }
1336 CallbackAction::CustomTemplate { template } => {
1337 let escaped = ruby_string_literal(template);
1338 setup_lines.push(format!(" {{ custom: {escaped} }}"));
1339 }
1340 }
1341 setup_lines.push(" end".to_string());
1342}