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::{
11 Assertion, CallbackAction, Fixture, FixtureGroup, TemplateReturnForm, ValidationErrorExpectation,
12};
13use alef_core::backend::GeneratedFile;
14use alef_core::config::ResolvedCrateConfig;
15use alef_core::hash::{self, CommentStyle};
16use alef_core::template_versions as tv;
17use anyhow::Result;
18use heck::ToSnakeCase;
19use std::collections::HashMap;
20use std::fmt::Write as FmtWrite;
21use std::path::PathBuf;
22
23use super::E2eCodegen;
24use super::client;
25
26pub struct RubyCodegen;
28
29impl E2eCodegen for RubyCodegen {
30 fn generate(
31 &self,
32 groups: &[FixtureGroup],
33 e2e_config: &E2eConfig,
34 config: &ResolvedCrateConfig,
35 _type_defs: &[alef_core::ir::TypeDef],
36 _enums: &[alef_core::ir::EnumDef],
37 ) -> Result<Vec<GeneratedFile>> {
38 let lang = self.language_name();
39 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
40
41 let mut files = Vec::new();
42
43 let call = &e2e_config.call;
45 let overrides = call.overrides.get(lang);
46 let module_path = overrides
47 .and_then(|o| o.module.as_ref())
48 .cloned()
49 .unwrap_or_else(|| call.module.clone());
50 let class_name = overrides.and_then(|o| o.class.as_ref()).cloned();
51 let options_type = overrides.and_then(|o| o.options_type.clone());
52 let empty_enum_fields = HashMap::new();
53 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
54 let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
55
56 let ruby_pkg = e2e_config.resolve_package("ruby");
58 let gem_name = ruby_pkg
59 .as_ref()
60 .and_then(|p| p.name.as_ref())
61 .cloned()
62 .unwrap_or_else(|| config.name.replace('-', "_"));
63 let gem_path = ruby_pkg
64 .as_ref()
65 .and_then(|p| p.path.as_ref())
66 .cloned()
67 .unwrap_or_else(|| "../../packages/ruby".to_string());
68 let gem_version = ruby_pkg
69 .as_ref()
70 .and_then(|p| p.version.as_ref())
71 .cloned()
72 .or_else(|| config.resolved_version())
73 .unwrap_or_else(|| "0.1.0".to_string());
74
75 files.push(GeneratedFile {
77 path: output_base.join("Gemfile"),
78 content: render_gemfile(&gem_name, &gem_path, &gem_version, e2e_config.dep_mode),
79 generated_header: false,
80 });
81
82 files.push(GeneratedFile {
84 path: output_base.join(".rubocop.yaml"),
85 content: render_rubocop_yaml(),
86 generated_header: false,
87 });
88
89 let has_http_fixtures = groups
91 .iter()
92 .flat_map(|g| g.fixtures.iter())
93 .any(|f| f.needs_mock_server());
94
95 let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
97 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
98 cc.args
99 .iter()
100 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
101 });
102
103 if has_file_fixtures || has_http_fixtures {
105 files.push(GeneratedFile {
106 path: output_base.join("spec").join("spec_helper.rb"),
107 content: render_spec_helper(
108 has_file_fixtures,
109 has_http_fixtures,
110 &e2e_config.test_documents_relative_from(1),
111 ),
112 generated_header: true,
113 });
114 }
115
116 let spec_base = output_base.join("spec");
118
119 for group in groups {
120 let active: Vec<&Fixture> = group
121 .fixtures
122 .iter()
123 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
124 .collect();
125
126 if active.is_empty() {
127 continue;
128 }
129
130 let field_resolver_pre = FieldResolver::new(
131 &e2e_config.fields,
132 &e2e_config.fields_optional,
133 &e2e_config.result_fields,
134 &e2e_config.fields_array,
135 &std::collections::HashSet::new(),
136 );
137 let has_any_output = active.iter().any(|f| {
139 if f.is_http_test() {
141 return true;
142 }
143 let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
144 let has_not_error = f.assertions.iter().any(|a| a.assertion_type == "not_error");
145 expects_error || has_not_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
146 });
147 if !has_any_output {
148 continue;
149 }
150
151 let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
152 let field_resolver = FieldResolver::new(
153 &e2e_config.fields,
154 &e2e_config.fields_optional,
155 &e2e_config.result_fields,
156 &e2e_config.fields_array,
157 &std::collections::HashSet::new(),
158 );
159 let content = render_spec_file(
160 &group.category,
161 &active,
162 &module_path,
163 class_name.as_deref(),
164 &gem_name,
165 &field_resolver,
166 options_type.as_deref(),
167 enum_fields,
168 result_is_simple,
169 e2e_config,
170 has_file_fixtures || has_http_fixtures,
171 );
172 files.push(GeneratedFile {
173 path: spec_base.join(filename),
174 content,
175 generated_header: true,
176 });
177 }
178
179 Ok(files)
180 }
181
182 fn language_name(&self) -> &'static str {
183 "ruby"
184 }
185}
186
187fn render_gemfile(
192 gem_name: &str,
193 gem_path: &str,
194 gem_version: &str,
195 dep_mode: crate::config::DependencyMode,
196) -> String {
197 let gem_line = match dep_mode {
198 crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
199 crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
200 };
201 crate::template_env::render(
202 "ruby/Gemfile.jinja",
203 minijinja::context! {
204 gem_line => gem_line,
205 rspec => tv::gem::RSPEC_E2E,
206 rubocop => tv::gem::RUBOCOP_E2E,
207 rubocop_rspec => tv::gem::RUBOCOP_RSPEC_E2E,
208 faraday => tv::gem::FARADAY,
209 },
210 )
211}
212
213fn render_spec_helper(has_file_fixtures: bool, has_http_fixtures: bool, test_documents_path: &str) -> String {
214 let header = hash::header(CommentStyle::Hash);
215 let mut out = header;
216 out.push_str("# frozen_string_literal: true\n");
217
218 if has_file_fixtures {
219 let _ = writeln!(out);
220 let _ = writeln!(
221 out,
222 "# Change to the configured test-documents directory so that fixture file paths like"
223 );
224 let _ = writeln!(
225 out,
226 "# \"pdf/fake_memo.pdf\" resolve correctly when running rspec from e2e/ruby/."
227 );
228 let _ = writeln!(
229 out,
230 "# spec_helper.rb lives in e2e/ruby/spec/; the fixtures dir resolves three directories up."
231 );
232 let _ = writeln!(
233 out,
234 "_test_documents = File.expand_path('{test_documents_path}', __dir__)"
235 );
236 let _ = writeln!(out, "Dir.chdir(_test_documents) if Dir.exist?(_test_documents)");
237 }
238
239 if has_http_fixtures {
240 out.push_str(
241 r#"
242require 'json'
243require 'open3'
244
245# Spawn the mock-server binary and set MOCK_SERVER_URL for all tests.
246RSpec.configure do |config|
247 config.before(:suite) do
248 bin = File.expand_path('../../rust/target/release/mock-server', __dir__)
249 fixtures_dir = File.expand_path('../../../fixtures', __dir__)
250 unless File.exist?(bin)
251 warn "mock-server binary not found at #{bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
252 end
253 stdin, stdout, _stderr, _wait = Open3.popen3(bin, fixtures_dir)
254 # Read startup lines: MOCK_SERVER_URL= then optional MOCK_SERVERS=.
255 url = nil
256 8.times do
257 line = stdout.readline.strip rescue break
258 if line.start_with?('MOCK_SERVER_URL=')
259 url = line.split('=', 2).last
260 ENV['MOCK_SERVER_URL'] = url
261 elsif line.start_with?('MOCK_SERVERS=')
262 json_val = line.split('=', 2).last
263 ENV['MOCK_SERVERS'] = json_val
264 JSON.parse(json_val).each do |fid, furl|
265 ENV["MOCK_SERVER_#{fid.upcase}"] = furl
266 end
267 break
268 elsif url
269 break
270 end
271 end
272 # Drain stdout in background.
273 Thread.new { stdout.read }
274 # Store stdin so we can close it on teardown.
275 @_mock_server_stdin = stdin
276 end
277
278 config.after(:suite) do
279 @_mock_server_stdin&.close
280 end
281end
282"#,
283 );
284 }
285
286 out
287}
288
289fn render_rubocop_yaml() -> String {
290 crate::template_env::render("ruby/rubocop.yml.jinja", minijinja::context! {})
291}
292
293#[allow(clippy::too_many_arguments)]
294fn render_spec_file(
295 category: &str,
296 fixtures: &[&Fixture],
297 module_path: &str,
298 class_name: Option<&str>,
299 gem_name: &str,
300 field_resolver: &FieldResolver,
301 options_type: Option<&str>,
302 enum_fields: &HashMap<String, String>,
303 result_is_simple: bool,
304 e2e_config: &E2eConfig,
305 needs_spec_helper: bool,
306) -> String {
307 let client_factory = e2e_config
309 .call
310 .overrides
311 .get("ruby")
312 .and_then(|o| o.client_factory.as_deref());
313
314 let require_name = if module_path.is_empty() { gem_name } else { module_path };
316 let mut requires = vec![require_name.replace('-', "_"), "json".to_string()];
317
318 let has_http = fixtures.iter().any(|f| f.is_http_test());
319 if needs_spec_helper || has_http {
320 requires.push("spec_helper".to_string());
321 }
322
323 let ruby_module = ruby_module_name(module_path);
325 let call_receiver = class_name.map(|s| s.to_string()).unwrap_or_else(|| ruby_module.clone());
326
327 let has_array_contains = fixtures.iter().any(|fixture| {
329 fixture.assertions.iter().any(|a| {
330 matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
331 && a.field
332 .as_deref()
333 .is_some_and(|f| !f.is_empty() && field_resolver.is_array(field_resolver.resolve(f)))
334 })
335 });
336
337 let mut examples = Vec::new();
339 for fixture in fixtures {
340 if fixture.http.is_some() {
341 let mut out = String::new();
343 render_http_example(&mut out, fixture);
344 examples.push(out);
345 } else {
346 let fixture_call = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
348 let fixture_call_overrides = fixture_call.overrides.get("ruby");
349 let raw_function_name = fixture_call_overrides
350 .and_then(|o| o.function.as_ref())
351 .cloned()
352 .unwrap_or_else(|| fixture_call.function.clone());
353
354 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
355 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
356 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
357 let is_streaming = raw_function_name == "chat_stream";
358
359 if !expects_error && !has_usable && !has_not_error && !is_streaming && fixture.assertions.is_empty() {
364 let test_name = sanitize_ident(&fixture.id);
365 let description = fixture.description.replace('\'', "\\'");
366 let mut out = String::new();
367 out.push_str(&format!(" it '{test_name}: {description}' do\n"));
368 out.push_str(" skip 'Fixture has no assertions to validate'\n");
369 out.push_str(" end\n");
370 examples.push(out);
371 } else {
372 let fixture_function_name = if is_streaming {
376 raw_function_name
377 } else if fixture_call.r#async && !raw_function_name.ends_with("_async") {
378 format!("{raw_function_name}_async")
379 } else {
380 raw_function_name
381 };
382 let fixture_result_var = &fixture_call.result_var;
383 let fixture_args = &fixture_call.args;
384 let fixture_client_factory = fixture_call_overrides
385 .and_then(|o| o.client_factory.as_deref())
386 .or(client_factory);
387 let fixture_options_type = fixture_call_overrides
388 .and_then(|o| o.options_type.as_deref())
389 .or(options_type);
390
391 let fixture_extra_args: Vec<String> =
392 fixture_call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
393 let fixture_result_is_simple =
396 fixture_call.result_is_simple || fixture_call_overrides.is_some_and(|o| o.result_is_simple);
397 let fixture_enum_fields: &HashMap<String, String> =
401 fixture_call_overrides.map(|o| &o.enum_fields).unwrap_or(enum_fields);
402 let example = if is_streaming {
403 render_chat_stream_example(
404 fixture,
405 &fixture_function_name,
406 &call_receiver,
407 &ruby_module,
408 fixture_args,
409 fixture_options_type,
410 fixture_enum_fields,
411 e2e_config,
412 fixture_client_factory,
413 &fixture_extra_args,
414 )
415 } else {
416 render_example(
417 fixture,
418 &fixture_function_name,
419 &call_receiver,
420 &ruby_module,
421 fixture_result_var,
422 fixture_args,
423 field_resolver,
424 fixture_options_type,
425 fixture_enum_fields,
426 fixture_result_is_simple,
427 e2e_config,
428 fixture_client_factory,
429 &fixture_extra_args,
430 )
431 };
432 examples.push(example);
433 }
434 }
435 }
436
437 let header = hash::header(CommentStyle::Hash);
438 crate::template_env::render(
439 "ruby/test_file.jinja",
440 minijinja::context! {
441 category => category,
442 requires => requires,
443 has_array_contains => has_array_contains,
444 has_http => has_http,
445 examples => examples,
446 header => header,
447 },
448 )
449}
450
451fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
454 fixture.assertions.iter().any(|a| {
455 if a.assertion_type == "not_error" || a.assertion_type == "error" {
457 return false;
458 }
459 if let Some(f) = &a.field {
461 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
462 return false;
463 }
464 if result_is_simple {
466 let f_lower = f.to_lowercase();
467 if !f.is_empty()
468 && f_lower != "content"
469 && (f_lower.starts_with("metadata")
470 || f_lower.starts_with("document")
471 || f_lower.starts_with("structure"))
472 {
473 return false;
474 }
475 }
476 }
477 true
478 })
479}
480
481struct RubyTestClientRenderer;
489
490impl client::TestClientRenderer for RubyTestClientRenderer {
491 fn language_name(&self) -> &'static str {
492 "ruby"
493 }
494
495 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
501 let escaped_description = description.replace('\'', "\\'");
502 let rendered = crate::template_env::render(
503 "ruby/http_test.jinja",
504 minijinja::context! {
505 fn_name => fn_name,
506 description => escaped_description,
507 skip_reason => skip_reason,
508 },
509 );
510 out.push_str(&rendered);
511 }
512
513 fn render_test_close(&self, out: &mut String) {
515 let rendered = crate::template_env::render("ruby/http_test_close.jinja", minijinja::context! {});
516 out.push_str(&rendered);
517 }
518
519 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
521 let method = ctx.method.to_uppercase();
522 let method_class = http_method_class(&method);
523
524 let has_body = ctx
525 .body
526 .is_some_and(|b| !matches!(b, serde_json::Value::String(s) if s.is_empty()));
527
528 let ruby_body = if has_body {
529 json_to_ruby(ctx.body.unwrap())
530 } else {
531 String::new()
532 };
533
534 let headers: Vec<minijinja::Value> = ctx
535 .headers
536 .iter()
537 .filter(|(k, _)| {
538 !(has_body && k.to_lowercase() == "content-type")
540 })
541 .map(|(k, v)| {
542 minijinja::context! {
543 key_literal => ruby_string_literal(k),
544 value_literal => ruby_string_literal(v),
545 }
546 })
547 .collect();
548
549 let rendered = crate::template_env::render(
550 "ruby/http_request.jinja",
551 minijinja::context! {
552 method_class => method_class,
553 path => ctx.path,
554 has_body => has_body,
555 ruby_body => ruby_body,
556 headers => headers,
557 response_var => ctx.response_var,
558 },
559 );
560 out.push_str(&rendered);
561 }
562
563 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
568 out.push_str(&format!(" expect({response_var}.code.to_i).to eq({status})\n"));
569 }
570
571 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
575 let header_key = name.to_lowercase();
576 let header_expr = format!("{response_var}[{}]", ruby_string_literal(&header_key));
577 let assertion = match expected {
578 "<<present>>" => {
579 format!(" expect({header_expr}).not_to be_nil\n")
580 }
581 "<<absent>>" => {
582 format!(" expect({header_expr}).to be_nil\n")
583 }
584 "<<uuid>>" => {
585 format!(
586 " 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"
587 )
588 }
589 literal => {
590 let ruby_val = ruby_string_literal(literal);
591 format!(" expect({header_expr}).to eq({ruby_val})\n")
592 }
593 };
594 out.push_str(&assertion);
595 }
596
597 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
602 match expected {
603 serde_json::Value::String(s) => {
604 let ruby_val = ruby_string_literal(s);
605 out.push_str(&format!(" expect({response_var}.body).to eq({ruby_val})\n"));
606 }
607 _ => {
608 let ruby_val = json_to_ruby(expected);
609 out.push_str(&format!(
610 " _body = {response_var}.body && !{response_var}.body.empty? ? JSON.parse({response_var}.body) : nil\n"
611 ));
612 out.push_str(&format!(" expect(_body).to eq({ruby_val})\n"));
613 }
614 }
615 }
616
617 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
619 if let Some(obj) = expected.as_object() {
620 out.push_str(&format!(" _body = JSON.parse({response_var}.body)\n"));
621 for (key, val) in obj {
622 let ruby_key = ruby_string_literal(key);
623 let ruby_val = json_to_ruby(val);
624 out.push_str(&format!(" expect(_body[{ruby_key}]).to eq({ruby_val})\n"));
625 }
626 }
627 }
628
629 fn render_assert_validation_errors(
632 &self,
633 out: &mut String,
634 response_var: &str,
635 errors: &[ValidationErrorExpectation],
636 ) {
637 for err in errors {
638 let msg_lit = ruby_string_literal(&err.msg);
639 out.push_str(&format!(" _body = JSON.parse({response_var}.body)\n"));
640 out.push_str(" _errors = _body['errors'] || []\n");
641 out.push_str(&format!(
642 " expect(_errors.map {{ |e| e['msg'] }}).to include({msg_lit})\n"
643 ));
644 }
645 }
646}
647
648fn render_http_example(out: &mut String, fixture: &Fixture) {
654 if fixture
658 .http
659 .as_ref()
660 .is_some_and(|h| h.expected_response.status_code == 101)
661 {
662 if let Some(http) = fixture.http.as_ref() {
663 let description = fixture.description.replace('\'', "\\'");
664 let method = http.request.method.to_uppercase();
665 let path = &http.request.path;
666 let rendered = crate::template_env::render(
667 "ruby/http_101_skip.jinja",
668 minijinja::context! {
669 method => method,
670 path => path,
671 description => description,
672 },
673 );
674 out.push_str(&rendered);
675 }
676 return;
677 }
678
679 client::http_call::render_http_test(out, &RubyTestClientRenderer, fixture);
680}
681
682fn http_method_class(method: &str) -> String {
685 let mut chars = method.chars();
686 match chars.next() {
687 None => String::new(),
688 Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
689 }
690}
691
692#[allow(clippy::too_many_arguments)]
704fn render_chat_stream_example(
705 fixture: &Fixture,
706 function_name: &str,
707 call_receiver: &str,
708 module_name: &str,
709 args: &[crate::config::ArgMapping],
710 options_type: Option<&str>,
711 enum_fields: &HashMap<String, String>,
712 e2e_config: &E2eConfig,
713 client_factory: Option<&str>,
714 extra_args: &[String],
715) -> String {
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 let fixture_id = fixture.id.clone();
720
721 let (mut setup_lines, args_str) = build_args_and_setup(
722 &fixture.input,
723 args,
724 call_receiver,
725 module_name,
726 options_type,
727 enum_fields,
728 false,
729 fixture,
730 );
731
732 let mut final_args = args_str;
733 if !extra_args.is_empty() {
734 let extra_str = extra_args.join(", ");
735 if final_args.is_empty() {
736 final_args = extra_str;
737 } else {
738 final_args = format!("{final_args}, {extra_str}");
739 }
740 }
741
742 let mut needs_finish_reason = false;
745 let mut needs_tool_calls_json = false;
746 let mut needs_tool_calls_0_function_name = false;
747 let mut needs_total_tokens = false;
748 for a in &fixture.assertions {
749 if let Some(f) = a.field.as_deref() {
750 match f {
751 "finish_reason" => needs_finish_reason = true,
752 "tool_calls" => needs_tool_calls_json = true,
753 "tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
754 "usage.total_tokens" => needs_total_tokens = true,
755 _ => {}
756 }
757 }
758 }
759
760 let mut out = String::new();
761 out.push_str(&format!(" it '{test_name}: {description}' do\n"));
762
763 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
765 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
766 if let Some(cf) = client_factory {
767 if has_mock && let Some(key_var) = api_key_var {
768 let mock_url_expr = format!("\"#{{ENV['MOCK_SERVER_URL']}}/fixtures/{fixture_id}\"");
769 out.push_str(&format!(" api_key = ENV['{key_var}']\n"));
770 out.push_str(" if api_key && !api_key.empty?\n");
771 out.push_str(&format!(
772 " warn \"{test_name}: using real API ({key_var} is set)\"\n"
773 ));
774 out.push_str(&format!(" client = {call_receiver}.{cf}(api_key)\n"));
775 out.push_str(" else\n");
776 out.push_str(&format!(
777 " warn \"{test_name}: using mock server ({key_var} not set)\"\n"
778 ));
779 out.push_str(&format!(" mock_url = {mock_url_expr}\n"));
780 out.push_str(&format!(" client = {call_receiver}.{cf}('test-key', mock_url)\n"));
781 out.push_str(" end\n");
782 } else if has_mock {
783 let base_url_expr = if fixture.has_host_root_route() {
784 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
785 format!("(ENV.fetch('{env_key}', nil) || ENV.fetch('MOCK_SERVER_URL') + '/fixtures/{fixture_id}')")
786 } else {
787 format!("ENV.fetch('MOCK_SERVER_URL') + '/fixtures/{fixture_id}'")
788 };
789 out.push_str(&format!(
790 " client = {call_receiver}.{cf}('test-key', {base_url_expr})\n"
791 ));
792 } else if let Some(key_var) = api_key_var {
793 out.push_str(&format!(" api_key = ENV['{key_var}']\n"));
794 out.push_str(&format!(" skip '{key_var} not set' unless api_key\n"));
795 out.push_str(&format!(" client = {call_receiver}.{cf}(api_key)\n"));
796 } else {
797 out.push_str(&format!(" client = {call_receiver}.{cf}('test-key')\n"));
798 }
799 }
800
801 if let Some(visitor_spec) = &fixture.visitor {
803 let _ = build_ruby_visitor(&mut setup_lines, visitor_spec);
804 }
805 for line in &setup_lines {
806 out.push_str(&format!(" {line}\n"));
807 }
808
809 let call_expr = if client_factory.is_some() {
810 format!("client.{function_name}({final_args})")
811 } else {
812 format!("{call_receiver}.{function_name}({final_args})")
813 };
814
815 if expects_error {
816 out.push_str(&format!(" expect {{ {call_expr} {{ |_chunk| }} }}.to raise_error\n"));
817 out.push_str(" end\n");
818 return out;
819 }
820
821 out.push_str(" chunks = []\n");
823 out.push_str(" stream_content = ''.dup\n");
824 out.push_str(" stream_complete = false\n");
825 if needs_finish_reason {
826 out.push_str(" last_finish_reason = nil\n");
827 }
828 if needs_tool_calls_json {
829 out.push_str(" tool_calls_json = nil\n");
830 }
831 if needs_tool_calls_0_function_name {
832 out.push_str(" tool_calls_0_function_name = nil\n");
833 }
834 if needs_total_tokens {
835 out.push_str(" total_tokens = nil\n");
836 }
837 out.push_str(&format!(" {call_expr} do |chunk|\n"));
838 out.push_str(" chunks << chunk\n");
839 out.push_str(" choice = chunk.choices && chunk.choices[0]\n");
840 out.push_str(" if choice\n");
841 out.push_str(" delta = choice.delta\n");
842 out.push_str(" if delta && delta.content\n");
843 out.push_str(" stream_content << delta.content\n");
844 out.push_str(" end\n");
845 if needs_finish_reason {
846 out.push_str(" if choice.finish_reason\n");
847 out.push_str(" last_finish_reason = choice.finish_reason.to_s\n");
848 out.push_str(" end\n");
849 }
850 if needs_tool_calls_json || needs_tool_calls_0_function_name {
851 out.push_str(" tcs = delta && delta.tool_calls\n");
852 out.push_str(" if tcs && !tcs.empty?\n");
853 if needs_tool_calls_json {
854 out.push_str(
855 " tool_calls_json ||= tcs.map { |tc| { 'function' => { 'name' => (tc.function && tc.function.name rescue nil) } } }.to_json\n",
856 );
857 }
858 if needs_tool_calls_0_function_name {
859 out.push_str(
860 " tool_calls_0_function_name ||= (tcs[0].function && tcs[0].function.name rescue nil)\n",
861 );
862 }
863 out.push_str(" end\n");
864 }
865 out.push_str(" end\n");
866 if needs_total_tokens {
867 out.push_str(" if chunk.usage && chunk.usage.total_tokens\n");
868 out.push_str(" total_tokens = chunk.usage.total_tokens\n");
869 out.push_str(" end\n");
870 }
871 out.push_str(" end\n");
872 out.push_str(" stream_complete = true\n");
873
874 for assertion in &fixture.assertions {
876 emit_chat_stream_assertion(&mut out, assertion, e2e_config);
877 }
878
879 if !fixture
882 .assertions
883 .iter()
884 .any(|a| a.field.as_deref() == Some("stream_complete"))
885 {
886 out.push_str(" expect(stream_complete).to be(true)\n");
887 }
888
889 out.push_str(" end\n");
890 out
891}
892
893fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion, _e2e_config: &E2eConfig) {
898 let atype = assertion.assertion_type.as_str();
899 if atype == "not_error" || atype == "error" {
900 return;
901 }
902 let field = assertion.field.as_deref().unwrap_or("");
903
904 enum Kind {
905 Chunks,
906 Bool,
907 Str,
908 IntTokens,
909 Json,
910 Unsupported,
911 }
912
913 let (expr, kind) = match field {
914 "chunks" => ("chunks", Kind::Chunks),
915 "stream_content" => ("stream_content", Kind::Str),
916 "stream_complete" => ("stream_complete", Kind::Bool),
917 "no_chunks_after_done" => ("stream_complete", Kind::Bool),
918 "finish_reason" => ("last_finish_reason", Kind::Str),
919 "tool_calls" => ("tool_calls_json", Kind::Json),
920 "tool_calls[0].function.name" => ("tool_calls_0_function_name", Kind::Str),
921 "usage.total_tokens" => ("total_tokens", Kind::IntTokens),
922 _ => ("", Kind::Unsupported),
923 };
924
925 if matches!(kind, Kind::Unsupported) {
926 out.push_str(&format!(
927 " # skipped: streaming assertion on unsupported field '{field}'\n"
928 ));
929 return;
930 }
931
932 match (atype, &kind) {
933 ("count_min", Kind::Chunks) => {
934 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
935 out.push_str(&format!(" expect({expr}.length).to be >= {n}\n"));
936 }
937 }
938 ("count_equals", Kind::Chunks) => {
939 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
940 out.push_str(&format!(" expect({expr}.length).to eq({n})\n"));
941 }
942 }
943 ("equals", Kind::Str) => {
944 if let Some(val) = &assertion.value {
945 let rb_val = json_to_ruby(val);
946 out.push_str(&format!(" expect({expr}.to_s.strip).to eq({rb_val}.strip)\n"));
950 }
951 }
952 ("contains", Kind::Str) => {
953 if let Some(val) = &assertion.value {
954 let rb_val = json_to_ruby(val);
955 out.push_str(&format!(" expect({expr}.to_s).to include({rb_val})\n"));
956 }
957 }
958 ("not_empty", Kind::Str) => {
959 out.push_str(&format!(" expect({expr}.to_s).not_to be_empty\n"));
960 }
961 ("not_empty", Kind::Json) => {
962 out.push_str(&format!(" expect({expr}).not_to be_nil\n"));
963 }
964 ("is_empty", Kind::Str) => {
965 out.push_str(&format!(" expect({expr}.to_s).to be_empty\n"));
966 }
967 ("is_true", Kind::Bool) => {
968 out.push_str(&format!(" expect({expr}).to be(true)\n"));
969 }
970 ("is_false", Kind::Bool) => {
971 out.push_str(&format!(" expect({expr}).to be(false)\n"));
972 }
973 ("greater_than_or_equal", Kind::IntTokens) => {
974 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
975 out.push_str(&format!(" expect({expr}).to be >= {n}\n"));
976 }
977 }
978 ("equals", Kind::IntTokens) => {
979 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
980 out.push_str(&format!(" expect({expr}).to eq({n})\n"));
981 }
982 }
983 _ => {
984 out.push_str(&format!(
985 " # skipped: streaming assertion '{atype}' on field '{field}' not supported\n"
986 ));
987 }
988 }
989}
990
991#[allow(clippy::too_many_arguments)]
996fn render_example(
997 fixture: &Fixture,
998 function_name: &str,
999 call_receiver: &str,
1000 module_name: &str,
1001 result_var: &str,
1002 args: &[crate::config::ArgMapping],
1003 field_resolver: &FieldResolver,
1004 options_type: Option<&str>,
1005 enum_fields: &HashMap<String, String>,
1006 result_is_simple: bool,
1007 e2e_config: &E2eConfig,
1008 client_factory: Option<&str>,
1009 extra_args: &[String],
1010) -> String {
1011 let test_name = sanitize_ident(&fixture.id);
1012 let description = fixture.description.replace('\'', "\\'");
1013 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1014 let fixture_id = fixture.id.clone();
1015
1016 let (mut setup_lines, args_str) = build_args_and_setup(
1017 &fixture.input,
1018 args,
1019 call_receiver,
1020 module_name,
1021 options_type,
1022 enum_fields,
1023 result_is_simple,
1024 fixture,
1025 );
1026
1027 let mut visitor_arg = String::new();
1029 if let Some(visitor_spec) = &fixture.visitor {
1030 visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
1031 }
1032
1033 let mut final_args = if visitor_arg.is_empty() {
1034 args_str
1035 } else if args_str.is_empty() {
1036 visitor_arg
1037 } else {
1038 format!("{args_str}, {visitor_arg}")
1039 };
1040
1041 if !extra_args.is_empty() {
1043 let extra_str = extra_args.join(", ");
1044 if final_args.is_empty() {
1045 final_args = extra_str;
1046 } else {
1047 final_args = format!("{final_args}, {extra_str}");
1048 }
1049 }
1050
1051 let call_expr = if client_factory.is_some() {
1053 format!("client.{function_name}({final_args})")
1054 } else {
1055 format!("{call_receiver}.{function_name}({final_args})")
1056 };
1057
1058 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
1060
1061 let mut assertions_rendered = String::new();
1063 for assertion in &fixture.assertions {
1064 render_assertion(
1065 &mut assertions_rendered,
1066 assertion,
1067 result_var,
1068 field_resolver,
1069 result_is_simple,
1070 e2e_config,
1071 enum_fields,
1072 );
1073 }
1074
1075 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1076 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1077 let has_mock_and_key = has_mock && api_key_var.is_some();
1078 crate::template_env::render(
1079 "ruby/test_function.jinja",
1080 minijinja::context! {
1081 test_name => test_name,
1082 description => description,
1083 expects_error => expects_error,
1084 setup_lines => setup_lines,
1085 call_expr => call_expr,
1086 result_var => result_var,
1087 assertions_rendered => assertions_rendered,
1088 has_usable => has_usable,
1089 client_factory => client_factory,
1090 fixture_id => fixture_id,
1091 call_receiver => call_receiver,
1092 has_mock => has_mock,
1093 api_key_var => api_key_var,
1094 has_mock_and_key => has_mock_and_key,
1095 },
1096 )
1097}
1098
1099fn emit_ruby_batch_item_array(arr: &serde_json::Value, elem_type: &str, module_name: &str) -> String {
1104 if let Some(items) = arr.as_array() {
1105 let item_strs: Vec<String> = items
1106 .iter()
1107 .filter_map(|item| {
1108 if let Some(obj) = item.as_object() {
1109 match elem_type {
1110 "BatchBytesItem" => {
1111 let content = obj.get("content").and_then(|v| v.as_array());
1112 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1113 let config = obj.get("config");
1114 let content_code = if let Some(arr) = content {
1115 let bytes: Vec<String> =
1116 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1117 format!("[{}]", bytes.join(", "))
1119 } else {
1120 "[]".to_string()
1121 };
1122 let config_arg = if let Some(cfg) = config {
1123 if cfg.is_null() {
1124 "nil".to_string()
1125 } else {
1126 json_to_ruby(cfg)
1127 }
1128 } else {
1129 "nil".to_string()
1130 };
1131 Some(format!(
1132 "{}::{}.new(content: {}, mime_type: \"{}\", config: {})",
1133 module_name, elem_type, content_code, mime_type, config_arg
1134 ))
1135 }
1136 "BatchFileItem" => {
1137 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1138 let config = obj.get("config");
1139 let config_arg = if let Some(cfg) = config {
1140 if cfg.is_null() {
1141 "nil".to_string()
1142 } else {
1143 json_to_ruby(cfg)
1144 }
1145 } else {
1146 "nil".to_string()
1147 };
1148 Some(format!(
1149 "{}::{}.new(path: \"{}\", config: {})",
1150 module_name, elem_type, path, config_arg
1151 ))
1152 }
1153 _ => None,
1154 }
1155 } else {
1156 None
1157 }
1158 })
1159 .collect();
1160 format!("[{}]", item_strs.join(", "))
1161 } else {
1162 "[]".to_string()
1163 }
1164}
1165
1166#[allow(clippy::too_many_arguments)]
1167fn build_args_and_setup(
1168 input: &serde_json::Value,
1169 args: &[crate::config::ArgMapping],
1170 call_receiver: &str,
1171 module_name: &str,
1172 options_type: Option<&str>,
1173 enum_fields: &HashMap<String, String>,
1174 result_is_simple: bool,
1175 fixture: &crate::fixture::Fixture,
1176) -> (Vec<String>, String) {
1177 let fixture_id = &fixture.id;
1178 if args.is_empty() {
1179 let is_empty_input = match input {
1183 serde_json::Value::Null => true,
1184 serde_json::Value::Object(m) => m.is_empty(),
1185 _ => false,
1186 };
1187 if is_empty_input {
1188 return (Vec::new(), String::new());
1189 }
1190 return (Vec::new(), json_to_ruby(input));
1191 }
1192
1193 let mut setup_lines: Vec<String> = Vec::new();
1194 let mut parts: Vec<String> = Vec::new();
1195 let mut skipped_optional_count: usize = 0;
1198
1199 for arg in args {
1200 if arg.arg_type == "mock_url" {
1201 for _ in 0..skipped_optional_count {
1203 parts.push("nil".to_string());
1204 }
1205 skipped_optional_count = 0;
1206 if fixture.has_host_root_route() {
1207 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1208 setup_lines.push(format!(
1209 "{} = ENV.fetch('{env_key}', nil) || \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
1210 arg.name,
1211 ));
1212 } else {
1213 setup_lines.push(format!(
1214 "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
1215 arg.name,
1216 ));
1217 }
1218 parts.push(arg.name.clone());
1219 continue;
1220 }
1221
1222 if arg.arg_type == "bytes" {
1224 for _ in 0..skipped_optional_count {
1226 parts.push("nil".to_string());
1227 }
1228 skipped_optional_count = 0;
1229 let resolved = resolve_field(input, &arg.field);
1230 if let Some(s) = resolved.as_str() {
1231 if is_file_path(s) {
1232 setup_lines.push(format!("{} = File.read(\"{}\").bytes", arg.name, s));
1234 } else if is_base64(s) {
1235 setup_lines.push(format!("{} = Base64.decode64(\"{}\").bytes", arg.name, s));
1237 } else {
1238 let escaped = ruby_string_literal(s);
1240 setup_lines.push(format!("{} = {}.b.bytes", arg.name, escaped));
1241 }
1242 parts.push(arg.name.clone());
1243 } else {
1244 parts.push("nil".to_string());
1245 }
1246 continue;
1247 }
1248
1249 if arg.arg_type == "file_path" {
1251 for _ in 0..skipped_optional_count {
1253 parts.push("nil".to_string());
1254 }
1255 skipped_optional_count = 0;
1256 let resolved = resolve_field(input, &arg.field);
1257 if let Some(s) = resolved.as_str() {
1258 let escaped = ruby_string_literal(s);
1259 parts.push(escaped);
1260 } else if arg.optional {
1261 skipped_optional_count += 1;
1262 continue;
1263 } else {
1264 parts.push("''".to_string());
1265 }
1266 continue;
1267 }
1268
1269 if arg.arg_type == "handle" {
1270 for _ in 0..skipped_optional_count {
1272 parts.push("nil".to_string());
1273 }
1274 skipped_optional_count = 0;
1275 let constructor_name = format!("create_{}", arg.name.to_snake_case());
1277 let config_value = resolve_field(input, &arg.field);
1278 if config_value.is_null()
1279 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1280 {
1281 setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
1282 } else {
1283 let literal = json_to_ruby(config_value);
1284 let name = &arg.name;
1285 setup_lines.push(format!("{name}_config = {literal}"));
1286 setup_lines.push(format!(
1287 "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
1288 arg.name,
1289 name = name,
1290 ));
1291 }
1292 parts.push(arg.name.clone());
1293 continue;
1294 }
1295
1296 let resolved = resolve_field(input, &arg.field);
1297 let val = if resolved.is_null() { None } else { Some(resolved) };
1298 match val {
1299 None | Some(serde_json::Value::Null) if arg.optional => {
1300 skipped_optional_count += 1;
1302 continue;
1303 }
1304 None | Some(serde_json::Value::Null) => {
1305 for _ in 0..skipped_optional_count {
1307 parts.push("nil".to_string());
1308 }
1309 skipped_optional_count = 0;
1310 let default_val = match arg.arg_type.as_str() {
1311 "string" => "''".to_string(),
1312 "int" | "integer" => "0".to_string(),
1313 "float" | "number" => "0.0".to_string(),
1314 "bool" | "boolean" => "false".to_string(),
1315 _ => "nil".to_string(),
1316 };
1317 parts.push(default_val);
1318 }
1319 Some(v) => {
1320 for _ in 0..skipped_optional_count {
1322 parts.push("nil".to_string());
1323 }
1324 skipped_optional_count = 0;
1325 if arg.arg_type == "json_object" && !v.is_null() {
1328 if let Some(elem_type) = &arg.element_type {
1330 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1331 parts.push(emit_ruby_batch_item_array(v, elem_type, module_name));
1332 continue;
1333 }
1334 }
1335 if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
1337 let kwargs: Vec<String> = obj
1338 .iter()
1339 .map(|(k, vv)| {
1340 let snake_key = k.to_snake_case();
1341 let rb_val = if enum_fields.contains_key(k) {
1342 if let Some(s) = vv.as_str() {
1343 let snake_val = s.to_snake_case();
1344 format!("'{snake_val}'")
1345 } else {
1346 json_to_ruby(vv)
1347 }
1348 } else {
1349 json_to_ruby(vv)
1350 };
1351 format!("{snake_key}: {rb_val}")
1352 })
1353 .collect();
1354 if result_is_simple {
1355 parts.push(format!("{{{}}}", kwargs.join(", ")));
1356 } else {
1357 parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
1358 }
1359 continue;
1360 }
1361 }
1362 parts.push(json_to_ruby(v));
1363 }
1364 }
1365 }
1366
1367 (setup_lines, parts.join(", "))
1368}
1369
1370fn render_assertion(
1371 out: &mut String,
1372 assertion: &Assertion,
1373 result_var: &str,
1374 field_resolver: &FieldResolver,
1375 result_is_simple: bool,
1376 e2e_config: &E2eConfig,
1377 per_call_enum_fields: &HashMap<String, String>,
1378) {
1379 if result_is_simple {
1383 if let Some(f) = &assertion.field {
1384 if !f.is_empty() {
1385 match assertion.assertion_type.as_str() {
1386 "not_empty" => {
1387 out.push_str(&format!(" expect({result_var}.to_s).not_to be_empty\n"));
1388 return;
1389 }
1390 "is_empty" => {
1391 out.push_str(&format!(" expect({result_var}.to_s).to be_empty\n"));
1392 return;
1393 }
1394 "count_equals" => {
1395 if let Some(val) = &assertion.value {
1396 let rb_val = json_to_ruby(val);
1397 out.push_str(&format!(" expect({result_var}.length).to eq({rb_val})\n"));
1398 }
1399 return;
1400 }
1401 "count_min" => {
1402 if let Some(val) = &assertion.value {
1403 let rb_val = json_to_ruby(val);
1404 out.push_str(&format!(" expect({result_var}.length).to be >= {rb_val}\n"));
1405 }
1406 return;
1407 }
1408 _ => {
1409 out.push_str(&format!(
1410 " # skipped: field '{f}' not applicable for simple result type\n"
1411 ));
1412 return;
1413 }
1414 }
1415 }
1416 }
1417 }
1418 if let Some(f) = &assertion.field {
1421 match f.as_str() {
1422 "chunks_have_content" => {
1423 let pred = format!("({result_var}.chunks || []).all? {{ |c| c.content && !c.content.empty? }}");
1424 match assertion.assertion_type.as_str() {
1425 "is_true" => {
1426 out.push_str(&format!(" expect({pred}).to be(true)\n"));
1427 }
1428 "is_false" => {
1429 out.push_str(&format!(" expect({pred}).to be(false)\n"));
1430 }
1431 _ => {
1432 out.push_str(&format!(
1433 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
1434 ));
1435 }
1436 }
1437 return;
1438 }
1439 "chunks_have_embeddings" => {
1440 let pred =
1441 format!("({result_var}.chunks || []).all? {{ |c| !c.embedding.nil? && !c.embedding.empty? }}");
1442 match assertion.assertion_type.as_str() {
1443 "is_true" => {
1444 out.push_str(&format!(" expect({pred}).to be(true)\n"));
1445 }
1446 "is_false" => {
1447 out.push_str(&format!(" expect({pred}).to be(false)\n"));
1448 }
1449 _ => {
1450 out.push_str(&format!(
1451 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
1452 ));
1453 }
1454 }
1455 return;
1456 }
1457 "embeddings" => {
1461 match assertion.assertion_type.as_str() {
1462 "count_equals" => {
1463 if let Some(val) = &assertion.value {
1464 let rb_val = json_to_ruby(val);
1465 out.push_str(&format!(" expect({result_var}.length).to eq({rb_val})\n"));
1466 }
1467 }
1468 "count_min" => {
1469 if let Some(val) = &assertion.value {
1470 let rb_val = json_to_ruby(val);
1471 out.push_str(&format!(" expect({result_var}.length).to be >= {rb_val}\n"));
1472 }
1473 }
1474 "not_empty" => {
1475 out.push_str(&format!(" expect({result_var}).not_to be_empty\n"));
1476 }
1477 "is_empty" => {
1478 out.push_str(&format!(" expect({result_var}).to be_empty\n"));
1479 }
1480 _ => {
1481 out.push_str(" # skipped: unsupported assertion type on synthetic field 'embeddings'\n");
1482 }
1483 }
1484 return;
1485 }
1486 "embedding_dimensions" => {
1487 let expr = format!("({result_var}.empty? ? 0 : {result_var}[0].length)");
1488 match assertion.assertion_type.as_str() {
1489 "equals" => {
1490 if let Some(val) = &assertion.value {
1491 let rb_val = json_to_ruby(val);
1492 out.push_str(&format!(" expect({expr}).to eq({rb_val})\n"));
1493 }
1494 }
1495 "greater_than" => {
1496 if let Some(val) = &assertion.value {
1497 let rb_val = json_to_ruby(val);
1498 out.push_str(&format!(" expect({expr}).to be > {rb_val}\n"));
1499 }
1500 }
1501 _ => {
1502 out.push_str(
1503 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n",
1504 );
1505 }
1506 }
1507 return;
1508 }
1509 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1510 let pred = match f.as_str() {
1511 "embeddings_valid" => {
1512 format!("{result_var}.all? {{ |e| !e.empty? }}")
1513 }
1514 "embeddings_finite" => {
1515 format!("{result_var}.all? {{ |e| e.all? {{ |v| v.finite? }} }}")
1516 }
1517 "embeddings_non_zero" => {
1518 format!("{result_var}.all? {{ |e| e.any? {{ |v| v != 0.0 }} }}")
1519 }
1520 "embeddings_normalized" => {
1521 format!("{result_var}.all? {{ |e| n = e.sum {{ |v| v * v }}; (n - 1.0).abs < 1e-3 }}")
1522 }
1523 _ => unreachable!(),
1524 };
1525 match assertion.assertion_type.as_str() {
1526 "is_true" => {
1527 out.push_str(&format!(" expect({pred}).to be(true)\n"));
1528 }
1529 "is_false" => {
1530 out.push_str(&format!(" expect({pred}).to be(false)\n"));
1531 }
1532 _ => {
1533 out.push_str(&format!(
1534 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
1535 ));
1536 }
1537 }
1538 return;
1539 }
1540 "keywords" | "keywords_count" => {
1543 out.push_str(&format!(
1544 " # skipped: field '{f}' not available on Ruby ExtractionResult\n"
1545 ));
1546 return;
1547 }
1548 _ => {}
1549 }
1550 }
1551
1552 if let Some(f) = &assertion.field {
1554 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1555 out.push_str(&format!(" # skipped: field '{f}' not available on result type\n"));
1556 return;
1557 }
1558 }
1559
1560 if result_is_simple {
1562 if let Some(f) = &assertion.field {
1563 let f_lower = f.to_lowercase();
1564 if !f.is_empty()
1565 && f_lower != "content"
1566 && (f_lower.starts_with("metadata")
1567 || f_lower.starts_with("document")
1568 || f_lower.starts_with("structure"))
1569 {
1570 return;
1571 }
1572 }
1573 }
1574
1575 let field_expr = match &assertion.field {
1579 Some(f) if !f.is_empty() && (!result_is_simple || !f.eq_ignore_ascii_case("content")) => {
1580 field_resolver.accessor(f, "ruby", result_var)
1581 }
1582 _ => result_var.to_string(),
1583 };
1584
1585 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1593 let resolved = field_resolver.resolve(f);
1594 e2e_config.fields_enum.contains(f)
1595 || e2e_config.fields_enum.contains(resolved)
1596 || per_call_enum_fields.contains_key(f)
1597 || per_call_enum_fields.contains_key(resolved)
1598 });
1599 let stripped_field_expr = if result_is_simple {
1600 format!("{field_expr}.to_s.strip")
1601 } else if field_is_enum {
1602 format!("{field_expr}.to_s")
1603 } else {
1604 field_expr.clone()
1605 };
1606
1607 let field_is_array = assertion
1610 .field
1611 .as_deref()
1612 .filter(|f| !f.is_empty())
1613 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1614
1615 match assertion.assertion_type.as_str() {
1616 "equals" => {
1617 if let Some(expected) = &assertion.value {
1618 let is_boolean_val = expected.as_bool().is_some();
1619 let bool_val = expected
1620 .as_bool()
1621 .map(|b| if b { "true" } else { "false" })
1622 .unwrap_or("");
1623 let rb_val = json_to_ruby(expected);
1624 let cmp_expr = if expected.is_string() && !field_is_enum {
1628 format!("{stripped_field_expr}.to_s.strip")
1629 } else {
1630 stripped_field_expr.clone()
1631 };
1632 let cmp_expected = if expected.is_string() && !field_is_enum {
1633 format!("{rb_val}.strip")
1634 } else {
1635 rb_val
1636 };
1637
1638 let rendered = crate::template_env::render(
1639 "ruby/assertion.jinja",
1640 minijinja::context! {
1641 assertion_type => "equals",
1642 stripped_field_expr => cmp_expr,
1643 is_boolean_val => is_boolean_val,
1644 bool_val => bool_val,
1645 expected_val => cmp_expected,
1646 },
1647 );
1648 out.push_str(&rendered);
1649 }
1650 }
1651 "contains" => {
1652 if let Some(expected) = &assertion.value {
1653 let rb_val = json_to_ruby(expected);
1654 let rendered = crate::template_env::render(
1655 "ruby/assertion.jinja",
1656 minijinja::context! {
1657 assertion_type => "contains",
1658 field_expr => field_expr.clone(),
1659 field_is_array => field_is_array && expected.is_string(),
1660 expected_val => rb_val,
1661 },
1662 );
1663 out.push_str(&rendered);
1664 }
1665 }
1666 "contains_all" => {
1667 if let Some(values) = &assertion.values {
1668 let values_list: Vec<String> = values.iter().map(json_to_ruby).collect();
1669 let rendered = crate::template_env::render(
1670 "ruby/assertion.jinja",
1671 minijinja::context! {
1672 assertion_type => "contains_all",
1673 field_expr => field_expr.clone(),
1674 field_is_array => field_is_array,
1675 values_list => values_list,
1676 },
1677 );
1678 out.push_str(&rendered);
1679 }
1680 }
1681 "not_contains" => {
1682 if let Some(expected) = &assertion.value {
1683 let rb_val = json_to_ruby(expected);
1684 let rendered = crate::template_env::render(
1685 "ruby/assertion.jinja",
1686 minijinja::context! {
1687 assertion_type => "not_contains",
1688 field_expr => field_expr.clone(),
1689 field_is_array => field_is_array && expected.is_string(),
1690 expected_val => rb_val,
1691 },
1692 );
1693 out.push_str(&rendered);
1694 }
1695 }
1696 "not_empty" => {
1697 let rendered = crate::template_env::render(
1698 "ruby/assertion.jinja",
1699 minijinja::context! {
1700 assertion_type => "not_empty",
1701 field_expr => field_expr.clone(),
1702 },
1703 );
1704 out.push_str(&rendered);
1705 }
1706 "is_empty" => {
1707 let rendered = crate::template_env::render(
1708 "ruby/assertion.jinja",
1709 minijinja::context! {
1710 assertion_type => "is_empty",
1711 field_expr => field_expr.clone(),
1712 },
1713 );
1714 out.push_str(&rendered);
1715 }
1716 "contains_any" => {
1717 if let Some(values) = &assertion.values {
1718 let items: Vec<String> = values.iter().map(json_to_ruby).collect();
1719 let rendered = crate::template_env::render(
1720 "ruby/assertion.jinja",
1721 minijinja::context! {
1722 assertion_type => "contains_any",
1723 field_expr => field_expr.clone(),
1724 values_list => items,
1725 },
1726 );
1727 out.push_str(&rendered);
1728 }
1729 }
1730 "greater_than" => {
1731 if let Some(val) = &assertion.value {
1732 let rb_val = json_to_ruby(val);
1733 let rendered = crate::template_env::render(
1734 "ruby/assertion.jinja",
1735 minijinja::context! {
1736 assertion_type => "greater_than",
1737 field_expr => field_expr.clone(),
1738 expected_val => rb_val,
1739 },
1740 );
1741 out.push_str(&rendered);
1742 }
1743 }
1744 "less_than" => {
1745 if let Some(val) = &assertion.value {
1746 let rb_val = json_to_ruby(val);
1747 let rendered = crate::template_env::render(
1748 "ruby/assertion.jinja",
1749 minijinja::context! {
1750 assertion_type => "less_than",
1751 field_expr => field_expr.clone(),
1752 expected_val => rb_val,
1753 },
1754 );
1755 out.push_str(&rendered);
1756 }
1757 }
1758 "greater_than_or_equal" => {
1759 if let Some(val) = &assertion.value {
1760 let rb_val = json_to_ruby(val);
1761 let rendered = crate::template_env::render(
1762 "ruby/assertion.jinja",
1763 minijinja::context! {
1764 assertion_type => "greater_than_or_equal",
1765 field_expr => field_expr.clone(),
1766 expected_val => rb_val,
1767 },
1768 );
1769 out.push_str(&rendered);
1770 }
1771 }
1772 "less_than_or_equal" => {
1773 if let Some(val) = &assertion.value {
1774 let rb_val = json_to_ruby(val);
1775 let rendered = crate::template_env::render(
1776 "ruby/assertion.jinja",
1777 minijinja::context! {
1778 assertion_type => "less_than_or_equal",
1779 field_expr => field_expr.clone(),
1780 expected_val => rb_val,
1781 },
1782 );
1783 out.push_str(&rendered);
1784 }
1785 }
1786 "starts_with" => {
1787 if let Some(expected) = &assertion.value {
1788 let rb_val = json_to_ruby(expected);
1789 let rendered = crate::template_env::render(
1790 "ruby/assertion.jinja",
1791 minijinja::context! {
1792 assertion_type => "starts_with",
1793 field_expr => field_expr.clone(),
1794 expected_val => rb_val,
1795 },
1796 );
1797 out.push_str(&rendered);
1798 }
1799 }
1800 "ends_with" => {
1801 if let Some(expected) = &assertion.value {
1802 let rb_val = json_to_ruby(expected);
1803 let rendered = crate::template_env::render(
1804 "ruby/assertion.jinja",
1805 minijinja::context! {
1806 assertion_type => "ends_with",
1807 field_expr => field_expr.clone(),
1808 expected_val => rb_val,
1809 },
1810 );
1811 out.push_str(&rendered);
1812 }
1813 }
1814 "min_length" | "max_length" | "count_min" | "count_equals" => {
1815 if let Some(val) = &assertion.value {
1816 if let Some(n) = val.as_u64() {
1817 let rendered = crate::template_env::render(
1818 "ruby/assertion.jinja",
1819 minijinja::context! {
1820 assertion_type => assertion.assertion_type.as_str(),
1821 field_expr => field_expr.clone(),
1822 check_n => n,
1823 },
1824 );
1825 out.push_str(&rendered);
1826 }
1827 }
1828 }
1829 "is_true" => {
1830 let rendered = crate::template_env::render(
1831 "ruby/assertion.jinja",
1832 minijinja::context! {
1833 assertion_type => "is_true",
1834 field_expr => field_expr.clone(),
1835 },
1836 );
1837 out.push_str(&rendered);
1838 }
1839 "is_false" => {
1840 let rendered = crate::template_env::render(
1841 "ruby/assertion.jinja",
1842 minijinja::context! {
1843 assertion_type => "is_false",
1844 field_expr => field_expr.clone(),
1845 },
1846 );
1847 out.push_str(&rendered);
1848 }
1849 "method_result" => {
1850 if let Some(method_name) = &assertion.method {
1851 let lang = "ruby";
1853 let call = &e2e_config.call;
1854 let overrides = call.overrides.get(lang);
1855 let module_path = overrides
1856 .and_then(|o| o.module.as_ref())
1857 .cloned()
1858 .unwrap_or_else(|| call.module.clone());
1859 let call_receiver = ruby_module_name(&module_path);
1860
1861 let call_expr =
1862 build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
1863 let check = assertion.check.as_deref().unwrap_or("is_true");
1864
1865 let (check_val_str, is_boolean_check, bool_check_val, check_n_val) = match check {
1866 "equals" => {
1867 if let Some(val) = &assertion.value {
1868 let is_bool = val.as_bool().is_some();
1869 let bool_str = val.as_bool().map(|b| if b { "true" } else { "false" }).unwrap_or("");
1870 let rb_val = json_to_ruby(val);
1871 (rb_val, is_bool, bool_str.to_string(), 0)
1872 } else {
1873 (String::new(), false, String::new(), 0)
1874 }
1875 }
1876 "greater_than_or_equal" => {
1877 if let Some(val) = &assertion.value {
1878 (json_to_ruby(val), false, String::new(), 0)
1879 } else {
1880 (String::new(), false, String::new(), 0)
1881 }
1882 }
1883 "count_min" => {
1884 if let Some(val) = &assertion.value {
1885 let n = val.as_u64().unwrap_or(0);
1886 (String::new(), false, String::new(), n)
1887 } else {
1888 (String::new(), false, String::new(), 0)
1889 }
1890 }
1891 "contains" => {
1892 if let Some(val) = &assertion.value {
1893 (json_to_ruby(val), false, String::new(), 0)
1894 } else {
1895 (String::new(), false, String::new(), 0)
1896 }
1897 }
1898 _ => (String::new(), false, String::new(), 0),
1899 };
1900
1901 let rendered = crate::template_env::render(
1902 "ruby/assertion.jinja",
1903 minijinja::context! {
1904 assertion_type => "method_result",
1905 call_expr => call_expr,
1906 check => check,
1907 check_val => check_val_str,
1908 is_boolean_check => is_boolean_check,
1909 bool_check_val => bool_check_val,
1910 check_n => check_n_val,
1911 },
1912 );
1913 out.push_str(&rendered);
1914 } else {
1915 panic!("Ruby e2e generator: method_result assertion missing 'method' field");
1916 }
1917 }
1918 "matches_regex" => {
1919 if let Some(expected) = &assertion.value {
1920 let rb_val = json_to_ruby(expected);
1921 let rendered = crate::template_env::render(
1922 "ruby/assertion.jinja",
1923 minijinja::context! {
1924 assertion_type => "matches_regex",
1925 field_expr => field_expr.clone(),
1926 expected_val => rb_val,
1927 },
1928 );
1929 out.push_str(&rendered);
1930 }
1931 }
1932 "not_error" => {
1933 }
1935 "error" => {
1936 }
1938 other => {
1939 panic!("Ruby e2e generator: unsupported assertion type: {other}");
1940 }
1941 }
1942}
1943
1944fn build_ruby_method_call(
1947 call_receiver: &str,
1948 result_var: &str,
1949 method_name: &str,
1950 args: Option<&serde_json::Value>,
1951) -> String {
1952 match method_name {
1953 "root_child_count" => format!("{result_var}.root_node.child_count"),
1954 "root_node_type" => format!("{result_var}.root_node.type"),
1955 "named_children_count" => format!("{result_var}.root_node.named_child_count"),
1956 "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
1957 "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
1958 "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
1959 "contains_node_type" => {
1960 let node_type = args
1961 .and_then(|a| a.get("node_type"))
1962 .and_then(|v| v.as_str())
1963 .unwrap_or("");
1964 format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
1965 }
1966 "find_nodes_by_type" => {
1967 let node_type = args
1968 .and_then(|a| a.get("node_type"))
1969 .and_then(|v| v.as_str())
1970 .unwrap_or("");
1971 format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
1972 }
1973 "run_query" => {
1974 let query_source = args
1975 .and_then(|a| a.get("query_source"))
1976 .and_then(|v| v.as_str())
1977 .unwrap_or("");
1978 let language = args
1979 .and_then(|a| a.get("language"))
1980 .and_then(|v| v.as_str())
1981 .unwrap_or("");
1982 format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1983 }
1984 _ => format!("{result_var}.{method_name}"),
1985 }
1986}
1987
1988fn ruby_module_name(module_path: &str) -> String {
1991 use heck::ToUpperCamelCase;
1992 module_path.to_upper_camel_case()
1993}
1994
1995fn json_to_ruby(value: &serde_json::Value) -> String {
1997 match value {
1998 serde_json::Value::String(s) => ruby_string_literal(s),
1999 serde_json::Value::Bool(true) => "true".to_string(),
2000 serde_json::Value::Bool(false) => "false".to_string(),
2001 serde_json::Value::Number(n) => n.to_string(),
2002 serde_json::Value::Null => "nil".to_string(),
2003 serde_json::Value::Array(arr) => {
2004 let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
2005 format!("[{}]", items.join(", "))
2006 }
2007 serde_json::Value::Object(map) => {
2008 let items: Vec<String> = map
2009 .iter()
2010 .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
2011 .collect();
2012 format!("{{ {} }}", items.join(", "))
2013 }
2014 }
2015}
2016
2017fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
2023 setup_lines.push("visitor = Class.new do".to_string());
2024 for (method_name, action) in &visitor_spec.callbacks {
2025 emit_ruby_visitor_method(setup_lines, method_name, action);
2026 }
2027 setup_lines.push("end.new".to_string());
2028 "visitor".to_string()
2029}
2030
2031fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
2033 let params = match method_name {
2034 "visit_link" => "ctx, href, text, title",
2035 "visit_image" => "ctx, src, alt, title",
2036 "visit_heading" => "ctx, level, text, id",
2037 "visit_code_block" => "ctx, lang, code",
2038 "visit_code_inline"
2039 | "visit_strong"
2040 | "visit_emphasis"
2041 | "visit_strikethrough"
2042 | "visit_underline"
2043 | "visit_subscript"
2044 | "visit_superscript"
2045 | "visit_mark"
2046 | "visit_button"
2047 | "visit_summary"
2048 | "visit_figcaption"
2049 | "visit_definition_term"
2050 | "visit_definition_description" => "ctx, text",
2051 "visit_text" => "ctx, text",
2052 "visit_list_item" => "ctx, ordered, marker, text",
2053 "visit_blockquote" => "ctx, content, depth",
2054 "visit_table_row" => "ctx, cells, is_header",
2055 "visit_custom_element" => "ctx, tag_name, html",
2056 "visit_form" => "ctx, action_url, method",
2057 "visit_input" => "ctx, input_type, name, value",
2058 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
2059 "visit_details" => "ctx, is_open",
2060 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
2061 "visit_list_start" => "ctx, ordered",
2062 "visit_list_end" => "ctx, ordered, output",
2063 _ => "ctx",
2064 };
2065
2066 let (action_type, action_value, return_form) = match action {
2068 CallbackAction::Skip => ("skip", String::new(), "dict"),
2069 CallbackAction::Continue => ("continue", String::new(), "dict"),
2070 CallbackAction::PreserveHtml => ("preserve_html", String::new(), "dict"),
2071 CallbackAction::Custom { output } => {
2072 let escaped = ruby_string_literal(output);
2073 ("custom", escaped, "dict")
2074 }
2075 CallbackAction::CustomTemplate { template, return_form } => {
2076 let interpolated = ruby_template_to_interpolation(template);
2077 let form = match return_form {
2078 TemplateReturnForm::Dict => "dict",
2079 TemplateReturnForm::BareString => "bare_string",
2080 };
2081 ("custom_template", format!("\"{interpolated}\""), form)
2082 }
2083 };
2084
2085 let rendered = crate::template_env::render(
2086 "ruby/visitor_method.jinja",
2087 minijinja::context! {
2088 method_name => method_name,
2089 params => params,
2090 action_type => action_type,
2091 action_value => action_value,
2092 return_form => return_form,
2093 },
2094 );
2095 for line in rendered.lines() {
2096 setup_lines.push(line.to_string());
2097 }
2098}
2099
2100fn is_file_path(s: &str) -> bool {
2105 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
2106 return false;
2107 }
2108
2109 let first = s.chars().next().unwrap_or('\0');
2110 if first.is_ascii_alphanumeric() || first == '_' {
2111 if let Some(slash_pos) = s.find('/') {
2112 if slash_pos > 0 {
2113 let after_slash = &s[slash_pos + 1..];
2114 if after_slash.contains('.') && !after_slash.is_empty() {
2115 return true;
2116 }
2117 }
2118 }
2119 }
2120
2121 false
2122}
2123
2124fn is_base64(s: &str) -> bool {
2127 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
2128 return false;
2129 }
2130
2131 if is_file_path(s) {
2132 return false;
2133 }
2134
2135 true
2136}