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 fixture_call.returns_void,
428 e2e_config,
429 fixture_client_factory,
430 &fixture_extra_args,
431 )
432 };
433 examples.push(example);
434 }
435 }
436 }
437
438 let header = hash::header(CommentStyle::Hash);
439 crate::template_env::render(
440 "ruby/test_file.jinja",
441 minijinja::context! {
442 category => category,
443 requires => requires,
444 has_array_contains => has_array_contains,
445 has_http => has_http,
446 examples => examples,
447 header => header,
448 },
449 )
450}
451
452fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
455 fixture.assertions.iter().any(|a| {
456 if a.assertion_type == "not_error" || a.assertion_type == "error" {
458 return false;
459 }
460 if let Some(f) = &a.field {
462 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
463 return false;
464 }
465 if result_is_simple {
467 let f_lower = f.to_lowercase();
468 if !f.is_empty()
469 && f_lower != "content"
470 && (f_lower.starts_with("metadata")
471 || f_lower.starts_with("document")
472 || f_lower.starts_with("structure"))
473 {
474 return false;
475 }
476 }
477 }
478 true
479 })
480}
481
482struct RubyTestClientRenderer;
490
491impl client::TestClientRenderer for RubyTestClientRenderer {
492 fn language_name(&self) -> &'static str {
493 "ruby"
494 }
495
496 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
502 let escaped_description = description.replace('\'', "\\'");
503 let rendered = crate::template_env::render(
504 "ruby/http_test.jinja",
505 minijinja::context! {
506 fn_name => fn_name,
507 description => escaped_description,
508 skip_reason => skip_reason,
509 },
510 );
511 out.push_str(&rendered);
512 }
513
514 fn render_test_close(&self, out: &mut String) {
516 let rendered = crate::template_env::render("ruby/http_test_close.jinja", minijinja::context! {});
517 out.push_str(&rendered);
518 }
519
520 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
522 let method = ctx.method.to_uppercase();
523 let method_class = http_method_class(&method);
524
525 let has_body = ctx
526 .body
527 .is_some_and(|b| !matches!(b, serde_json::Value::String(s) if s.is_empty()));
528
529 let ruby_body = if has_body {
530 json_to_ruby(ctx.body.unwrap())
531 } else {
532 String::new()
533 };
534
535 let headers: Vec<minijinja::Value> = ctx
536 .headers
537 .iter()
538 .filter(|(k, _)| {
539 !(has_body && k.to_lowercase() == "content-type")
541 })
542 .map(|(k, v)| {
543 minijinja::context! {
544 key_literal => ruby_string_literal(k),
545 value_literal => ruby_string_literal(v),
546 }
547 })
548 .collect();
549
550 let rendered = crate::template_env::render(
551 "ruby/http_request.jinja",
552 minijinja::context! {
553 method_class => method_class,
554 path => ctx.path,
555 has_body => has_body,
556 ruby_body => ruby_body,
557 headers => headers,
558 response_var => ctx.response_var,
559 },
560 );
561 out.push_str(&rendered);
562 }
563
564 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
569 out.push_str(&format!(" expect({response_var}.code.to_i).to eq({status})\n"));
570 }
571
572 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
576 let header_key = name.to_lowercase();
577 let header_expr = format!("{response_var}[{}]", ruby_string_literal(&header_key));
578 let assertion = match expected {
579 "<<present>>" => {
580 format!(" expect({header_expr}).not_to be_nil\n")
581 }
582 "<<absent>>" => {
583 format!(" expect({header_expr}).to be_nil\n")
584 }
585 "<<uuid>>" => {
586 format!(
587 " 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"
588 )
589 }
590 literal => {
591 let ruby_val = ruby_string_literal(literal);
592 format!(" expect({header_expr}).to eq({ruby_val})\n")
593 }
594 };
595 out.push_str(&assertion);
596 }
597
598 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
603 match expected {
604 serde_json::Value::String(s) => {
605 let ruby_val = ruby_string_literal(s);
606 out.push_str(&format!(" expect({response_var}.body).to eq({ruby_val})\n"));
607 }
608 _ => {
609 let ruby_val = json_to_ruby(expected);
610 out.push_str(&format!(
611 " _body = {response_var}.body && !{response_var}.body.empty? ? JSON.parse({response_var}.body) : nil\n"
612 ));
613 out.push_str(&format!(" expect(_body).to eq({ruby_val})\n"));
614 }
615 }
616 }
617
618 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
620 if let Some(obj) = expected.as_object() {
621 out.push_str(&format!(" _body = JSON.parse({response_var}.body)\n"));
622 for (key, val) in obj {
623 let ruby_key = ruby_string_literal(key);
624 let ruby_val = json_to_ruby(val);
625 out.push_str(&format!(" expect(_body[{ruby_key}]).to eq({ruby_val})\n"));
626 }
627 }
628 }
629
630 fn render_assert_validation_errors(
633 &self,
634 out: &mut String,
635 response_var: &str,
636 errors: &[ValidationErrorExpectation],
637 ) {
638 for err in errors {
639 let msg_lit = ruby_string_literal(&err.msg);
640 out.push_str(&format!(" _body = JSON.parse({response_var}.body)\n"));
641 out.push_str(" _errors = _body['errors'] || []\n");
642 out.push_str(&format!(
643 " expect(_errors.map {{ |e| e['msg'] }}).to include({msg_lit})\n"
644 ));
645 }
646 }
647}
648
649fn render_http_example(out: &mut String, fixture: &Fixture) {
655 if fixture
659 .http
660 .as_ref()
661 .is_some_and(|h| h.expected_response.status_code == 101)
662 {
663 if let Some(http) = fixture.http.as_ref() {
664 let description = fixture.description.replace('\'', "\\'");
665 let method = http.request.method.to_uppercase();
666 let path = &http.request.path;
667 let rendered = crate::template_env::render(
668 "ruby/http_101_skip.jinja",
669 minijinja::context! {
670 method => method,
671 path => path,
672 description => description,
673 },
674 );
675 out.push_str(&rendered);
676 }
677 return;
678 }
679
680 client::http_call::render_http_test(out, &RubyTestClientRenderer, fixture);
681}
682
683fn http_method_class(method: &str) -> String {
686 let mut chars = method.chars();
687 match chars.next() {
688 None => String::new(),
689 Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
690 }
691}
692
693#[allow(clippy::too_many_arguments)]
705fn render_chat_stream_example(
706 fixture: &Fixture,
707 function_name: &str,
708 call_receiver: &str,
709 module_name: &str,
710 args: &[crate::config::ArgMapping],
711 options_type: Option<&str>,
712 enum_fields: &HashMap<String, String>,
713 e2e_config: &E2eConfig,
714 client_factory: Option<&str>,
715 extra_args: &[String],
716) -> String {
717 let test_name = sanitize_ident(&fixture.id);
718 let description = fixture.description.replace('\'', "\\'");
719 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
720 let fixture_id = fixture.id.clone();
721
722 let (mut setup_lines, args_str) = build_args_and_setup(
723 &fixture.input,
724 args,
725 call_receiver,
726 module_name,
727 options_type,
728 enum_fields,
729 false,
730 fixture,
731 );
732
733 let mut final_args = args_str;
734 if !extra_args.is_empty() {
735 let extra_str = extra_args.join(", ");
736 if final_args.is_empty() {
737 final_args = extra_str;
738 } else {
739 final_args = format!("{final_args}, {extra_str}");
740 }
741 }
742
743 let mut needs_finish_reason = false;
746 let mut needs_tool_calls_json = false;
747 let mut needs_tool_calls_0_function_name = false;
748 let mut needs_total_tokens = false;
749 for a in &fixture.assertions {
750 if let Some(f) = a.field.as_deref() {
751 match f {
752 "finish_reason" => needs_finish_reason = true,
753 "tool_calls" => needs_tool_calls_json = true,
754 "tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
755 "usage.total_tokens" => needs_total_tokens = true,
756 _ => {}
757 }
758 }
759 }
760
761 let mut out = String::new();
762 out.push_str(&format!(" it '{test_name}: {description}' do\n"));
763
764 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
766 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
767 if let Some(cf) = client_factory {
768 if has_mock && let Some(key_var) = api_key_var {
769 let mock_url_expr = format!("\"#{{ENV['MOCK_SERVER_URL']}}/fixtures/{fixture_id}\"");
770 out.push_str(&format!(" api_key = ENV['{key_var}']\n"));
771 out.push_str(" if api_key && !api_key.empty?\n");
772 out.push_str(&format!(
773 " warn \"{test_name}: using real API ({key_var} is set)\"\n"
774 ));
775 out.push_str(&format!(" client = {call_receiver}.{cf}(api_key)\n"));
776 out.push_str(" else\n");
777 out.push_str(&format!(
778 " warn \"{test_name}: using mock server ({key_var} not set)\"\n"
779 ));
780 out.push_str(&format!(" mock_url = {mock_url_expr}\n"));
781 out.push_str(&format!(" client = {call_receiver}.{cf}('test-key', mock_url)\n"));
782 out.push_str(" end\n");
783 } else if has_mock {
784 let base_url_expr = if fixture.has_host_root_route() {
785 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
786 format!("(ENV.fetch('{env_key}', nil) || ENV.fetch('MOCK_SERVER_URL') + '/fixtures/{fixture_id}')")
787 } else {
788 format!("ENV.fetch('MOCK_SERVER_URL') + '/fixtures/{fixture_id}'")
789 };
790 out.push_str(&format!(
791 " client = {call_receiver}.{cf}('test-key', {base_url_expr})\n"
792 ));
793 } else if let Some(key_var) = api_key_var {
794 out.push_str(&format!(" api_key = ENV['{key_var}']\n"));
795 out.push_str(&format!(" skip '{key_var} not set' unless api_key\n"));
796 out.push_str(&format!(" client = {call_receiver}.{cf}(api_key)\n"));
797 } else {
798 out.push_str(&format!(" client = {call_receiver}.{cf}('test-key')\n"));
799 }
800 }
801
802 if let Some(visitor_spec) = &fixture.visitor {
804 let _ = build_ruby_visitor(&mut setup_lines, visitor_spec);
805 }
806 for line in &setup_lines {
807 out.push_str(&format!(" {line}\n"));
808 }
809
810 let call_expr = if client_factory.is_some() {
811 format!("client.{function_name}({final_args})")
812 } else {
813 format!("{call_receiver}.{function_name}({final_args})")
814 };
815
816 if expects_error {
817 out.push_str(&format!(" expect {{ {call_expr} {{ |_chunk| }} }}.to raise_error\n"));
818 out.push_str(" end\n");
819 return out;
820 }
821
822 out.push_str(" chunks = []\n");
824 out.push_str(" stream_content = ''.dup\n");
825 out.push_str(" stream_complete = false\n");
826 if needs_finish_reason {
827 out.push_str(" last_finish_reason = nil\n");
828 }
829 if needs_tool_calls_json {
830 out.push_str(" tool_calls_json = nil\n");
831 }
832 if needs_tool_calls_0_function_name {
833 out.push_str(" tool_calls_0_function_name = nil\n");
834 }
835 if needs_total_tokens {
836 out.push_str(" total_tokens = nil\n");
837 }
838 out.push_str(&format!(" {call_expr} do |chunk|\n"));
839 out.push_str(" chunks << chunk\n");
840 out.push_str(" choice = chunk.choices && chunk.choices[0]\n");
841 out.push_str(" if choice\n");
842 out.push_str(" delta = choice.delta\n");
843 out.push_str(" if delta && delta.content\n");
844 out.push_str(" stream_content << delta.content\n");
845 out.push_str(" end\n");
846 if needs_finish_reason {
847 out.push_str(" if choice.finish_reason\n");
848 out.push_str(" last_finish_reason = choice.finish_reason.to_s\n");
849 out.push_str(" end\n");
850 }
851 if needs_tool_calls_json || needs_tool_calls_0_function_name {
852 out.push_str(" tcs = delta && delta.tool_calls\n");
853 out.push_str(" if tcs && !tcs.empty?\n");
854 if needs_tool_calls_json {
855 out.push_str(
856 " tool_calls_json ||= tcs.map { |tc| { 'function' => { 'name' => (tc.function && tc.function.name rescue nil) } } }.to_json\n",
857 );
858 }
859 if needs_tool_calls_0_function_name {
860 out.push_str(
861 " tool_calls_0_function_name ||= (tcs[0].function && tcs[0].function.name rescue nil)\n",
862 );
863 }
864 out.push_str(" end\n");
865 }
866 out.push_str(" end\n");
867 if needs_total_tokens {
868 out.push_str(" if chunk.usage && chunk.usage.total_tokens\n");
869 out.push_str(" total_tokens = chunk.usage.total_tokens\n");
870 out.push_str(" end\n");
871 }
872 out.push_str(" end\n");
873 out.push_str(" stream_complete = true\n");
874
875 for assertion in &fixture.assertions {
877 emit_chat_stream_assertion(&mut out, assertion, e2e_config);
878 }
879
880 if !fixture
883 .assertions
884 .iter()
885 .any(|a| a.field.as_deref() == Some("stream_complete"))
886 {
887 out.push_str(" expect(stream_complete).to be(true)\n");
888 }
889
890 out.push_str(" end\n");
891 out
892}
893
894fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion, _e2e_config: &E2eConfig) {
899 let atype = assertion.assertion_type.as_str();
900 if atype == "not_error" || atype == "error" {
901 return;
902 }
903 let field = assertion.field.as_deref().unwrap_or("");
904
905 enum Kind {
906 Chunks,
907 Bool,
908 Str,
909 IntTokens,
910 Json,
911 Unsupported,
912 }
913
914 let (expr, kind) = match field {
915 "chunks" => ("chunks", Kind::Chunks),
916 "stream_content" => ("stream_content", Kind::Str),
917 "stream_complete" => ("stream_complete", Kind::Bool),
918 "no_chunks_after_done" => ("stream_complete", Kind::Bool),
919 "finish_reason" => ("last_finish_reason", Kind::Str),
920 "tool_calls" => ("tool_calls_json", Kind::Json),
921 "tool_calls[0].function.name" => ("tool_calls_0_function_name", Kind::Str),
922 "usage.total_tokens" => ("total_tokens", Kind::IntTokens),
923 _ => ("", Kind::Unsupported),
924 };
925
926 if matches!(kind, Kind::Unsupported) {
927 out.push_str(&format!(
928 " # skipped: streaming assertion on unsupported field '{field}'\n"
929 ));
930 return;
931 }
932
933 match (atype, &kind) {
934 ("count_min", Kind::Chunks) => {
935 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
936 out.push_str(&format!(" expect({expr}.length).to be >= {n}\n"));
937 }
938 }
939 ("count_equals", Kind::Chunks) => {
940 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
941 out.push_str(&format!(" expect({expr}.length).to eq({n})\n"));
942 }
943 }
944 ("equals", Kind::Str) => {
945 if let Some(val) = &assertion.value {
946 let rb_val = json_to_ruby(val);
947 out.push_str(&format!(" expect({expr}.to_s.strip).to eq({rb_val}.strip)\n"));
951 }
952 }
953 ("contains", Kind::Str) => {
954 if let Some(val) = &assertion.value {
955 let rb_val = json_to_ruby(val);
956 out.push_str(&format!(" expect({expr}.to_s).to include({rb_val})\n"));
957 }
958 }
959 ("not_empty", Kind::Str) => {
960 out.push_str(&format!(" expect({expr}.to_s).not_to be_empty\n"));
961 }
962 ("not_empty", Kind::Json) => {
963 out.push_str(&format!(" expect({expr}).not_to be_nil\n"));
964 }
965 ("is_empty", Kind::Str) => {
966 out.push_str(&format!(" expect({expr}.to_s).to be_empty\n"));
967 }
968 ("is_true", Kind::Bool) => {
969 out.push_str(&format!(" expect({expr}).to be(true)\n"));
970 }
971 ("is_false", Kind::Bool) => {
972 out.push_str(&format!(" expect({expr}).to be(false)\n"));
973 }
974 ("greater_than_or_equal", Kind::IntTokens) => {
975 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
976 out.push_str(&format!(" expect({expr}).to be >= {n}\n"));
977 }
978 }
979 ("equals", Kind::IntTokens) => {
980 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
981 out.push_str(&format!(" expect({expr}).to eq({n})\n"));
982 }
983 }
984 _ => {
985 out.push_str(&format!(
986 " # skipped: streaming assertion '{atype}' on field '{field}' not supported\n"
987 ));
988 }
989 }
990}
991
992#[allow(clippy::too_many_arguments)]
997fn render_example(
998 fixture: &Fixture,
999 function_name: &str,
1000 call_receiver: &str,
1001 module_name: &str,
1002 result_var: &str,
1003 args: &[crate::config::ArgMapping],
1004 field_resolver: &FieldResolver,
1005 options_type: Option<&str>,
1006 enum_fields: &HashMap<String, String>,
1007 result_is_simple: bool,
1008 returns_void: bool,
1009 e2e_config: &E2eConfig,
1010 client_factory: Option<&str>,
1011 extra_args: &[String],
1012) -> String {
1013 let test_name = sanitize_ident(&fixture.id);
1014 let description = fixture.description.replace('\'', "\\'");
1015 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1016 let fixture_id = fixture.id.clone();
1017
1018 let (mut setup_lines, args_str) = build_args_and_setup(
1019 &fixture.input,
1020 args,
1021 call_receiver,
1022 module_name,
1023 options_type,
1024 enum_fields,
1025 result_is_simple,
1026 fixture,
1027 );
1028
1029 let mut visitor_arg = String::new();
1031 if let Some(visitor_spec) = &fixture.visitor {
1032 visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
1033 }
1034
1035 let mut final_args = if visitor_arg.is_empty() {
1036 args_str
1037 } else if args_str.is_empty() {
1038 visitor_arg
1039 } else {
1040 format!("{args_str}, {visitor_arg}")
1041 };
1042
1043 if !extra_args.is_empty() {
1045 let extra_str = extra_args.join(", ");
1046 if final_args.is_empty() {
1047 final_args = extra_str;
1048 } else {
1049 final_args = format!("{final_args}, {extra_str}");
1050 }
1051 }
1052
1053 let call_expr = if client_factory.is_some() {
1055 format!("client.{function_name}({final_args})")
1056 } else {
1057 format!("{call_receiver}.{function_name}({final_args})")
1058 };
1059
1060 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
1062
1063 let mut assertions_rendered = String::new();
1065 for assertion in &fixture.assertions {
1066 render_assertion(
1067 &mut assertions_rendered,
1068 assertion,
1069 result_var,
1070 field_resolver,
1071 result_is_simple,
1072 e2e_config,
1073 enum_fields,
1074 );
1075 }
1076
1077 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1078 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1079 let has_mock_and_key = has_mock && api_key_var.is_some();
1080 crate::template_env::render(
1081 "ruby/test_function.jinja",
1082 minijinja::context! {
1083 test_name => test_name,
1084 description => description,
1085 expects_error => expects_error,
1086 setup_lines => setup_lines,
1087 call_expr => call_expr,
1088 result_var => result_var,
1089 assertions_rendered => assertions_rendered,
1090 has_usable => has_usable,
1091 returns_void => returns_void,
1092 client_factory => client_factory,
1093 fixture_id => fixture_id,
1094 call_receiver => call_receiver,
1095 has_mock => has_mock,
1096 api_key_var => api_key_var,
1097 has_mock_and_key => has_mock_and_key,
1098 },
1099 )
1100}
1101
1102fn emit_ruby_batch_item_array(arr: &serde_json::Value, elem_type: &str, module_name: &str) -> String {
1107 if let Some(items) = arr.as_array() {
1108 let item_strs: Vec<String> = items
1109 .iter()
1110 .filter_map(|item| {
1111 if let Some(obj) = item.as_object() {
1112 match elem_type {
1113 "BatchBytesItem" => {
1114 let content = obj.get("content").and_then(|v| v.as_array());
1115 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1116 let config = obj.get("config");
1117 let content_code = if let Some(arr) = content {
1118 let bytes: Vec<String> =
1119 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
1120 format!("[{}]", bytes.join(", "))
1122 } else {
1123 "[]".to_string()
1124 };
1125 let config_arg = if let Some(cfg) = config {
1126 if cfg.is_null() {
1127 "nil".to_string()
1128 } else {
1129 json_to_ruby(cfg)
1130 }
1131 } else {
1132 "nil".to_string()
1133 };
1134 Some(format!(
1135 "{}::{}.new(content: {}, mime_type: \"{}\", config: {})",
1136 module_name, elem_type, content_code, mime_type, config_arg
1137 ))
1138 }
1139 "BatchFileItem" => {
1140 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1141 let config = obj.get("config");
1142 let config_arg = if let Some(cfg) = config {
1143 if cfg.is_null() {
1144 "nil".to_string()
1145 } else {
1146 json_to_ruby(cfg)
1147 }
1148 } else {
1149 "nil".to_string()
1150 };
1151 Some(format!(
1152 "{}::{}.new(path: \"{}\", config: {})",
1153 module_name, elem_type, path, config_arg
1154 ))
1155 }
1156 _ => None,
1157 }
1158 } else {
1159 None
1160 }
1161 })
1162 .collect();
1163 format!("[{}]", item_strs.join(", "))
1164 } else {
1165 "[]".to_string()
1166 }
1167}
1168
1169#[allow(clippy::too_many_arguments)]
1170fn build_args_and_setup(
1171 input: &serde_json::Value,
1172 args: &[crate::config::ArgMapping],
1173 call_receiver: &str,
1174 module_name: &str,
1175 options_type: Option<&str>,
1176 enum_fields: &HashMap<String, String>,
1177 result_is_simple: bool,
1178 fixture: &crate::fixture::Fixture,
1179) -> (Vec<String>, String) {
1180 let fixture_id = &fixture.id;
1181 if args.is_empty() {
1182 let is_empty_input = match input {
1186 serde_json::Value::Null => true,
1187 serde_json::Value::Object(m) => m.is_empty(),
1188 _ => false,
1189 };
1190 if is_empty_input {
1191 return (Vec::new(), String::new());
1192 }
1193 return (Vec::new(), json_to_ruby(input));
1194 }
1195
1196 let mut setup_lines: Vec<String> = Vec::new();
1197 let mut parts: Vec<String> = Vec::new();
1198 let mut skipped_optional_count: usize = 0;
1201
1202 for arg in args {
1203 if arg.arg_type == "mock_url" {
1204 for _ in 0..skipped_optional_count {
1206 parts.push("nil".to_string());
1207 }
1208 skipped_optional_count = 0;
1209 if fixture.has_host_root_route() {
1210 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1211 setup_lines.push(format!(
1212 "{} = ENV.fetch('{env_key}', nil) || \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
1213 arg.name,
1214 ));
1215 } else {
1216 setup_lines.push(format!(
1217 "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
1218 arg.name,
1219 ));
1220 }
1221 parts.push(arg.name.clone());
1222 continue;
1223 }
1224
1225 if arg.arg_type == "bytes" {
1227 for _ in 0..skipped_optional_count {
1229 parts.push("nil".to_string());
1230 }
1231 skipped_optional_count = 0;
1232 let resolved = resolve_field(input, &arg.field);
1233 if let Some(s) = resolved.as_str() {
1234 if is_file_path(s) {
1235 setup_lines.push(format!("{} = File.read(\"{}\").bytes", arg.name, s));
1237 } else if is_base64(s) {
1238 setup_lines.push(format!("{} = Base64.decode64(\"{}\").bytes", arg.name, s));
1240 } else {
1241 let escaped = ruby_string_literal(s);
1243 setup_lines.push(format!("{} = {}.b.bytes", arg.name, escaped));
1244 }
1245 parts.push(arg.name.clone());
1246 } else {
1247 parts.push("nil".to_string());
1248 }
1249 continue;
1250 }
1251
1252 if arg.arg_type == "file_path" {
1254 for _ in 0..skipped_optional_count {
1256 parts.push("nil".to_string());
1257 }
1258 skipped_optional_count = 0;
1259 let resolved = resolve_field(input, &arg.field);
1260 if let Some(s) = resolved.as_str() {
1261 let escaped = ruby_string_literal(s);
1262 parts.push(escaped);
1263 } else if arg.optional {
1264 skipped_optional_count += 1;
1265 continue;
1266 } else {
1267 parts.push("''".to_string());
1268 }
1269 continue;
1270 }
1271
1272 if arg.arg_type == "handle" {
1273 for _ in 0..skipped_optional_count {
1275 parts.push("nil".to_string());
1276 }
1277 skipped_optional_count = 0;
1278 let constructor_name = format!("create_{}", arg.name.to_snake_case());
1280 let config_value = resolve_field(input, &arg.field);
1281 if config_value.is_null()
1282 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1283 {
1284 setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
1285 } else {
1286 let literal = json_to_ruby(config_value);
1287 let name = &arg.name;
1288 setup_lines.push(format!("{name}_config = {literal}"));
1289 setup_lines.push(format!(
1290 "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
1291 arg.name,
1292 name = name,
1293 ));
1294 }
1295 parts.push(arg.name.clone());
1296 continue;
1297 }
1298
1299 let resolved = resolve_field(input, &arg.field);
1300 let val = if resolved.is_null() { None } else { Some(resolved) };
1301 match val {
1302 None | Some(serde_json::Value::Null) if arg.optional => {
1303 skipped_optional_count += 1;
1305 continue;
1306 }
1307 None | Some(serde_json::Value::Null) => {
1308 for _ in 0..skipped_optional_count {
1310 parts.push("nil".to_string());
1311 }
1312 skipped_optional_count = 0;
1313 let default_val = match arg.arg_type.as_str() {
1314 "string" => "''".to_string(),
1315 "int" | "integer" => "0".to_string(),
1316 "float" | "number" => "0.0".to_string(),
1317 "bool" | "boolean" => "false".to_string(),
1318 _ => "nil".to_string(),
1319 };
1320 parts.push(default_val);
1321 }
1322 Some(v) => {
1323 for _ in 0..skipped_optional_count {
1325 parts.push("nil".to_string());
1326 }
1327 skipped_optional_count = 0;
1328 if arg.arg_type == "json_object" && !v.is_null() {
1331 if let Some(elem_type) = &arg.element_type {
1333 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1334 parts.push(emit_ruby_batch_item_array(v, elem_type, module_name));
1335 continue;
1336 }
1337 }
1338 if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
1340 let kwargs: Vec<String> = obj
1341 .iter()
1342 .map(|(k, vv)| {
1343 let snake_key = k.to_snake_case();
1344 let rb_val = if enum_fields.contains_key(k) {
1345 if let Some(s) = vv.as_str() {
1346 let snake_val = s.to_snake_case();
1347 format!("'{snake_val}'")
1348 } else {
1349 json_to_ruby(vv)
1350 }
1351 } else {
1352 json_to_ruby(vv)
1353 };
1354 format!("{snake_key}: {rb_val}")
1355 })
1356 .collect();
1357 if result_is_simple {
1358 parts.push(format!("{{{}}}", kwargs.join(", ")));
1359 } else {
1360 parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
1361 }
1362 continue;
1363 }
1364 }
1365 parts.push(json_to_ruby(v));
1366 }
1367 }
1368 }
1369
1370 (setup_lines, parts.join(", "))
1371}
1372
1373fn render_assertion(
1374 out: &mut String,
1375 assertion: &Assertion,
1376 result_var: &str,
1377 field_resolver: &FieldResolver,
1378 result_is_simple: bool,
1379 e2e_config: &E2eConfig,
1380 per_call_enum_fields: &HashMap<String, String>,
1381) {
1382 if result_is_simple {
1386 if let Some(f) = &assertion.field {
1387 if !f.is_empty() {
1388 match assertion.assertion_type.as_str() {
1389 "not_empty" => {
1390 out.push_str(&format!(" expect({result_var}.to_s).not_to be_empty\n"));
1391 return;
1392 }
1393 "is_empty" => {
1394 out.push_str(&format!(" expect({result_var}.to_s).to be_empty\n"));
1395 return;
1396 }
1397 "count_equals" => {
1398 if let Some(val) = &assertion.value {
1399 let rb_val = json_to_ruby(val);
1400 out.push_str(&format!(" expect({result_var}.length).to eq({rb_val})\n"));
1401 }
1402 return;
1403 }
1404 "count_min" => {
1405 if let Some(val) = &assertion.value {
1406 let rb_val = json_to_ruby(val);
1407 out.push_str(&format!(" expect({result_var}.length).to be >= {rb_val}\n"));
1408 }
1409 return;
1410 }
1411 _ => {
1412 out.push_str(&format!(
1413 " # skipped: field '{f}' not applicable for simple result type\n"
1414 ));
1415 return;
1416 }
1417 }
1418 }
1419 }
1420 }
1421 if let Some(f) = &assertion.field {
1424 match f.as_str() {
1425 "chunks_have_content" => {
1426 let pred = format!("({result_var}.chunks || []).all? {{ |c| c.content && !c.content.empty? }}");
1427 match assertion.assertion_type.as_str() {
1428 "is_true" => {
1429 out.push_str(&format!(" expect({pred}).to be(true)\n"));
1430 }
1431 "is_false" => {
1432 out.push_str(&format!(" expect({pred}).to be(false)\n"));
1433 }
1434 _ => {
1435 out.push_str(&format!(
1436 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
1437 ));
1438 }
1439 }
1440 return;
1441 }
1442 "chunks_have_embeddings" => {
1443 let pred =
1444 format!("({result_var}.chunks || []).all? {{ |c| !c.embedding.nil? && !c.embedding.empty? }}");
1445 match assertion.assertion_type.as_str() {
1446 "is_true" => {
1447 out.push_str(&format!(" expect({pred}).to be(true)\n"));
1448 }
1449 "is_false" => {
1450 out.push_str(&format!(" expect({pred}).to be(false)\n"));
1451 }
1452 _ => {
1453 out.push_str(&format!(
1454 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
1455 ));
1456 }
1457 }
1458 return;
1459 }
1460 "embeddings" => {
1464 match assertion.assertion_type.as_str() {
1465 "count_equals" => {
1466 if let Some(val) = &assertion.value {
1467 let rb_val = json_to_ruby(val);
1468 out.push_str(&format!(" expect({result_var}.length).to eq({rb_val})\n"));
1469 }
1470 }
1471 "count_min" => {
1472 if let Some(val) = &assertion.value {
1473 let rb_val = json_to_ruby(val);
1474 out.push_str(&format!(" expect({result_var}.length).to be >= {rb_val}\n"));
1475 }
1476 }
1477 "not_empty" => {
1478 out.push_str(&format!(" expect({result_var}).not_to be_empty\n"));
1479 }
1480 "is_empty" => {
1481 out.push_str(&format!(" expect({result_var}).to be_empty\n"));
1482 }
1483 _ => {
1484 out.push_str(" # skipped: unsupported assertion type on synthetic field 'embeddings'\n");
1485 }
1486 }
1487 return;
1488 }
1489 "embedding_dimensions" => {
1490 let expr = format!("({result_var}.empty? ? 0 : {result_var}[0].length)");
1491 match assertion.assertion_type.as_str() {
1492 "equals" => {
1493 if let Some(val) = &assertion.value {
1494 let rb_val = json_to_ruby(val);
1495 out.push_str(&format!(" expect({expr}).to eq({rb_val})\n"));
1496 }
1497 }
1498 "greater_than" => {
1499 if let Some(val) = &assertion.value {
1500 let rb_val = json_to_ruby(val);
1501 out.push_str(&format!(" expect({expr}).to be > {rb_val}\n"));
1502 }
1503 }
1504 _ => {
1505 out.push_str(
1506 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n",
1507 );
1508 }
1509 }
1510 return;
1511 }
1512 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1513 let pred = match f.as_str() {
1514 "embeddings_valid" => {
1515 format!("{result_var}.all? {{ |e| !e.empty? }}")
1516 }
1517 "embeddings_finite" => {
1518 format!("{result_var}.all? {{ |e| e.all? {{ |v| v.finite? }} }}")
1519 }
1520 "embeddings_non_zero" => {
1521 format!("{result_var}.all? {{ |e| e.any? {{ |v| v != 0.0 }} }}")
1522 }
1523 "embeddings_normalized" => {
1524 format!("{result_var}.all? {{ |e| n = e.sum {{ |v| v * v }}; (n - 1.0).abs < 1e-3 }}")
1525 }
1526 _ => unreachable!(),
1527 };
1528 match assertion.assertion_type.as_str() {
1529 "is_true" => {
1530 out.push_str(&format!(" expect({pred}).to be(true)\n"));
1531 }
1532 "is_false" => {
1533 out.push_str(&format!(" expect({pred}).to be(false)\n"));
1534 }
1535 _ => {
1536 out.push_str(&format!(
1537 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
1538 ));
1539 }
1540 }
1541 return;
1542 }
1543 "keywords" | "keywords_count" => {
1546 out.push_str(&format!(
1547 " # skipped: field '{f}' not available on Ruby ExtractionResult\n"
1548 ));
1549 return;
1550 }
1551 _ => {}
1552 }
1553 }
1554
1555 if let Some(f) = &assertion.field {
1557 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1558 out.push_str(&format!(" # skipped: field '{f}' not available on result type\n"));
1559 return;
1560 }
1561 }
1562
1563 if result_is_simple {
1565 if let Some(f) = &assertion.field {
1566 let f_lower = f.to_lowercase();
1567 if !f.is_empty()
1568 && f_lower != "content"
1569 && (f_lower.starts_with("metadata")
1570 || f_lower.starts_with("document")
1571 || f_lower.starts_with("structure"))
1572 {
1573 return;
1574 }
1575 }
1576 }
1577
1578 let field_expr = match &assertion.field {
1582 Some(f) if !f.is_empty() && (!result_is_simple || !f.eq_ignore_ascii_case("content")) => {
1583 field_resolver.accessor(f, "ruby", result_var)
1584 }
1585 _ => result_var.to_string(),
1586 };
1587
1588 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1596 let resolved = field_resolver.resolve(f);
1597 e2e_config.fields_enum.contains(f)
1598 || e2e_config.fields_enum.contains(resolved)
1599 || per_call_enum_fields.contains_key(f)
1600 || per_call_enum_fields.contains_key(resolved)
1601 });
1602 let expected_is_string = assertion.value.as_ref().is_some_and(|v| v.is_string());
1608 let stripped_field_expr = if result_is_simple && expected_is_string {
1609 format!("{field_expr}.to_s.strip")
1610 } else if field_is_enum {
1611 format!("{field_expr}.to_s")
1612 } else {
1613 field_expr.clone()
1614 };
1615
1616 let field_is_array = assertion
1619 .field
1620 .as_deref()
1621 .filter(|f| !f.is_empty())
1622 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1623
1624 match assertion.assertion_type.as_str() {
1625 "equals" => {
1626 if let Some(expected) = &assertion.value {
1627 let is_boolean_val = expected.as_bool().is_some();
1628 let bool_val = expected
1629 .as_bool()
1630 .map(|b| if b { "true" } else { "false" })
1631 .unwrap_or("");
1632 let rb_val = json_to_ruby(expected);
1633 let cmp_expr = if expected.is_string() && !field_is_enum {
1637 format!("{stripped_field_expr}.to_s.strip")
1638 } else {
1639 stripped_field_expr.clone()
1640 };
1641 let cmp_expected = if expected.is_string() && !field_is_enum {
1642 format!("{rb_val}.strip")
1643 } else {
1644 rb_val
1645 };
1646
1647 let rendered = crate::template_env::render(
1648 "ruby/assertion.jinja",
1649 minijinja::context! {
1650 assertion_type => "equals",
1651 stripped_field_expr => cmp_expr,
1652 is_boolean_val => is_boolean_val,
1653 bool_val => bool_val,
1654 expected_val => cmp_expected,
1655 },
1656 );
1657 out.push_str(&rendered);
1658 }
1659 }
1660 "contains" => {
1661 if let Some(expected) = &assertion.value {
1662 let rb_val = json_to_ruby(expected);
1663 let rendered = crate::template_env::render(
1664 "ruby/assertion.jinja",
1665 minijinja::context! {
1666 assertion_type => "contains",
1667 field_expr => field_expr.clone(),
1668 field_is_array => field_is_array && expected.is_string(),
1669 expected_val => rb_val,
1670 },
1671 );
1672 out.push_str(&rendered);
1673 }
1674 }
1675 "contains_all" => {
1676 if let Some(values) = &assertion.values {
1677 let values_list: Vec<String> = values.iter().map(json_to_ruby).collect();
1678 let rendered = crate::template_env::render(
1679 "ruby/assertion.jinja",
1680 minijinja::context! {
1681 assertion_type => "contains_all",
1682 field_expr => field_expr.clone(),
1683 field_is_array => field_is_array,
1684 values_list => values_list,
1685 },
1686 );
1687 out.push_str(&rendered);
1688 }
1689 }
1690 "not_contains" => {
1691 if let Some(expected) = &assertion.value {
1692 let rb_val = json_to_ruby(expected);
1693 let rendered = crate::template_env::render(
1694 "ruby/assertion.jinja",
1695 minijinja::context! {
1696 assertion_type => "not_contains",
1697 field_expr => field_expr.clone(),
1698 field_is_array => field_is_array && expected.is_string(),
1699 expected_val => rb_val,
1700 },
1701 );
1702 out.push_str(&rendered);
1703 }
1704 }
1705 "not_empty" => {
1706 let rendered = crate::template_env::render(
1707 "ruby/assertion.jinja",
1708 minijinja::context! {
1709 assertion_type => "not_empty",
1710 field_expr => field_expr.clone(),
1711 },
1712 );
1713 out.push_str(&rendered);
1714 }
1715 "is_empty" => {
1716 let rendered = crate::template_env::render(
1717 "ruby/assertion.jinja",
1718 minijinja::context! {
1719 assertion_type => "is_empty",
1720 field_expr => field_expr.clone(),
1721 },
1722 );
1723 out.push_str(&rendered);
1724 }
1725 "contains_any" => {
1726 if let Some(values) = &assertion.values {
1727 let items: Vec<String> = values.iter().map(json_to_ruby).collect();
1728 let rendered = crate::template_env::render(
1729 "ruby/assertion.jinja",
1730 minijinja::context! {
1731 assertion_type => "contains_any",
1732 field_expr => field_expr.clone(),
1733 values_list => items,
1734 },
1735 );
1736 out.push_str(&rendered);
1737 }
1738 }
1739 "greater_than" => {
1740 if let Some(val) = &assertion.value {
1741 let rb_val = json_to_ruby(val);
1742 let rendered = crate::template_env::render(
1743 "ruby/assertion.jinja",
1744 minijinja::context! {
1745 assertion_type => "greater_than",
1746 field_expr => field_expr.clone(),
1747 expected_val => rb_val,
1748 },
1749 );
1750 out.push_str(&rendered);
1751 }
1752 }
1753 "less_than" => {
1754 if let Some(val) = &assertion.value {
1755 let rb_val = json_to_ruby(val);
1756 let rendered = crate::template_env::render(
1757 "ruby/assertion.jinja",
1758 minijinja::context! {
1759 assertion_type => "less_than",
1760 field_expr => field_expr.clone(),
1761 expected_val => rb_val,
1762 },
1763 );
1764 out.push_str(&rendered);
1765 }
1766 }
1767 "greater_than_or_equal" => {
1768 if let Some(val) = &assertion.value {
1769 let rb_val = json_to_ruby(val);
1770 let rendered = crate::template_env::render(
1771 "ruby/assertion.jinja",
1772 minijinja::context! {
1773 assertion_type => "greater_than_or_equal",
1774 field_expr => field_expr.clone(),
1775 expected_val => rb_val,
1776 },
1777 );
1778 out.push_str(&rendered);
1779 }
1780 }
1781 "less_than_or_equal" => {
1782 if let Some(val) = &assertion.value {
1783 let rb_val = json_to_ruby(val);
1784 let rendered = crate::template_env::render(
1785 "ruby/assertion.jinja",
1786 minijinja::context! {
1787 assertion_type => "less_than_or_equal",
1788 field_expr => field_expr.clone(),
1789 expected_val => rb_val,
1790 },
1791 );
1792 out.push_str(&rendered);
1793 }
1794 }
1795 "starts_with" => {
1796 if let Some(expected) = &assertion.value {
1797 let rb_val = json_to_ruby(expected);
1798 let rendered = crate::template_env::render(
1799 "ruby/assertion.jinja",
1800 minijinja::context! {
1801 assertion_type => "starts_with",
1802 field_expr => field_expr.clone(),
1803 expected_val => rb_val,
1804 },
1805 );
1806 out.push_str(&rendered);
1807 }
1808 }
1809 "ends_with" => {
1810 if let Some(expected) = &assertion.value {
1811 let rb_val = json_to_ruby(expected);
1812 let rendered = crate::template_env::render(
1813 "ruby/assertion.jinja",
1814 minijinja::context! {
1815 assertion_type => "ends_with",
1816 field_expr => field_expr.clone(),
1817 expected_val => rb_val,
1818 },
1819 );
1820 out.push_str(&rendered);
1821 }
1822 }
1823 "min_length" | "max_length" | "count_min" | "count_equals" => {
1824 if let Some(val) = &assertion.value {
1825 if let Some(n) = val.as_u64() {
1826 let rendered = crate::template_env::render(
1827 "ruby/assertion.jinja",
1828 minijinja::context! {
1829 assertion_type => assertion.assertion_type.as_str(),
1830 field_expr => field_expr.clone(),
1831 check_n => n,
1832 },
1833 );
1834 out.push_str(&rendered);
1835 }
1836 }
1837 }
1838 "is_true" => {
1839 let rendered = crate::template_env::render(
1840 "ruby/assertion.jinja",
1841 minijinja::context! {
1842 assertion_type => "is_true",
1843 field_expr => field_expr.clone(),
1844 },
1845 );
1846 out.push_str(&rendered);
1847 }
1848 "is_false" => {
1849 let rendered = crate::template_env::render(
1850 "ruby/assertion.jinja",
1851 minijinja::context! {
1852 assertion_type => "is_false",
1853 field_expr => field_expr.clone(),
1854 },
1855 );
1856 out.push_str(&rendered);
1857 }
1858 "method_result" => {
1859 if let Some(method_name) = &assertion.method {
1860 let lang = "ruby";
1862 let call = &e2e_config.call;
1863 let overrides = call.overrides.get(lang);
1864 let module_path = overrides
1865 .and_then(|o| o.module.as_ref())
1866 .cloned()
1867 .unwrap_or_else(|| call.module.clone());
1868 let call_receiver = ruby_module_name(&module_path);
1869
1870 let call_expr =
1871 build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
1872 let check = assertion.check.as_deref().unwrap_or("is_true");
1873
1874 let (check_val_str, is_boolean_check, bool_check_val, check_n_val) = match check {
1875 "equals" => {
1876 if let Some(val) = &assertion.value {
1877 let is_bool = val.as_bool().is_some();
1878 let bool_str = val.as_bool().map(|b| if b { "true" } else { "false" }).unwrap_or("");
1879 let rb_val = json_to_ruby(val);
1880 (rb_val, is_bool, bool_str.to_string(), 0)
1881 } else {
1882 (String::new(), false, String::new(), 0)
1883 }
1884 }
1885 "greater_than_or_equal" => {
1886 if let Some(val) = &assertion.value {
1887 (json_to_ruby(val), false, String::new(), 0)
1888 } else {
1889 (String::new(), false, String::new(), 0)
1890 }
1891 }
1892 "count_min" => {
1893 if let Some(val) = &assertion.value {
1894 let n = val.as_u64().unwrap_or(0);
1895 (String::new(), false, String::new(), n)
1896 } else {
1897 (String::new(), false, String::new(), 0)
1898 }
1899 }
1900 "contains" => {
1901 if let Some(val) = &assertion.value {
1902 (json_to_ruby(val), false, String::new(), 0)
1903 } else {
1904 (String::new(), false, String::new(), 0)
1905 }
1906 }
1907 _ => (String::new(), false, String::new(), 0),
1908 };
1909
1910 let rendered = crate::template_env::render(
1911 "ruby/assertion.jinja",
1912 minijinja::context! {
1913 assertion_type => "method_result",
1914 call_expr => call_expr,
1915 check => check,
1916 check_val => check_val_str,
1917 is_boolean_check => is_boolean_check,
1918 bool_check_val => bool_check_val,
1919 check_n => check_n_val,
1920 },
1921 );
1922 out.push_str(&rendered);
1923 } else {
1924 panic!("Ruby e2e generator: method_result assertion missing 'method' field");
1925 }
1926 }
1927 "matches_regex" => {
1928 if let Some(expected) = &assertion.value {
1929 let rb_val = json_to_ruby(expected);
1930 let rendered = crate::template_env::render(
1931 "ruby/assertion.jinja",
1932 minijinja::context! {
1933 assertion_type => "matches_regex",
1934 field_expr => field_expr.clone(),
1935 expected_val => rb_val,
1936 },
1937 );
1938 out.push_str(&rendered);
1939 }
1940 }
1941 "not_error" => {
1942 }
1944 "error" => {
1945 }
1947 other => {
1948 panic!("Ruby e2e generator: unsupported assertion type: {other}");
1949 }
1950 }
1951}
1952
1953fn build_ruby_method_call(
1956 call_receiver: &str,
1957 result_var: &str,
1958 method_name: &str,
1959 args: Option<&serde_json::Value>,
1960) -> String {
1961 match method_name {
1962 "root_child_count" => format!("{result_var}.root_node.child_count"),
1963 "root_node_type" => format!("{result_var}.root_node.type"),
1964 "named_children_count" => format!("{result_var}.root_node.named_child_count"),
1965 "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
1966 "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
1967 "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
1968 "contains_node_type" => {
1969 let node_type = args
1970 .and_then(|a| a.get("node_type"))
1971 .and_then(|v| v.as_str())
1972 .unwrap_or("");
1973 format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
1974 }
1975 "find_nodes_by_type" => {
1976 let node_type = args
1977 .and_then(|a| a.get("node_type"))
1978 .and_then(|v| v.as_str())
1979 .unwrap_or("");
1980 format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
1981 }
1982 "run_query" => {
1983 let query_source = args
1984 .and_then(|a| a.get("query_source"))
1985 .and_then(|v| v.as_str())
1986 .unwrap_or("");
1987 let language = args
1988 .and_then(|a| a.get("language"))
1989 .and_then(|v| v.as_str())
1990 .unwrap_or("");
1991 format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1992 }
1993 _ => format!("{result_var}.{method_name}"),
1994 }
1995}
1996
1997fn ruby_module_name(module_path: &str) -> String {
2000 use heck::ToUpperCamelCase;
2001 module_path.to_upper_camel_case()
2002}
2003
2004fn json_to_ruby(value: &serde_json::Value) -> String {
2006 match value {
2007 serde_json::Value::String(s) => ruby_string_literal(s),
2008 serde_json::Value::Bool(true) => "true".to_string(),
2009 serde_json::Value::Bool(false) => "false".to_string(),
2010 serde_json::Value::Number(n) => n.to_string(),
2011 serde_json::Value::Null => "nil".to_string(),
2012 serde_json::Value::Array(arr) => {
2013 let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
2014 format!("[{}]", items.join(", "))
2015 }
2016 serde_json::Value::Object(map) => {
2017 let items: Vec<String> = map
2018 .iter()
2019 .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
2020 .collect();
2021 format!("{{ {} }}", items.join(", "))
2022 }
2023 }
2024}
2025
2026fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
2032 setup_lines.push("visitor = Class.new do".to_string());
2033 for (method_name, action) in &visitor_spec.callbacks {
2034 emit_ruby_visitor_method(setup_lines, method_name, action);
2035 }
2036 setup_lines.push("end.new".to_string());
2037 "visitor".to_string()
2038}
2039
2040fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
2042 let params = match method_name {
2043 "visit_link" => "ctx, href, text, title",
2044 "visit_image" => "ctx, src, alt, title",
2045 "visit_heading" => "ctx, level, text, id",
2046 "visit_code_block" => "ctx, lang, code",
2047 "visit_code_inline"
2048 | "visit_strong"
2049 | "visit_emphasis"
2050 | "visit_strikethrough"
2051 | "visit_underline"
2052 | "visit_subscript"
2053 | "visit_superscript"
2054 | "visit_mark"
2055 | "visit_button"
2056 | "visit_summary"
2057 | "visit_figcaption"
2058 | "visit_definition_term"
2059 | "visit_definition_description" => "ctx, text",
2060 "visit_text" => "ctx, text",
2061 "visit_list_item" => "ctx, ordered, marker, text",
2062 "visit_blockquote" => "ctx, content, depth",
2063 "visit_table_row" => "ctx, cells, is_header",
2064 "visit_custom_element" => "ctx, tag_name, html",
2065 "visit_form" => "ctx, action_url, method",
2066 "visit_input" => "ctx, input_type, name, value",
2067 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
2068 "visit_details" => "ctx, is_open",
2069 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
2070 "visit_list_start" => "ctx, ordered",
2071 "visit_list_end" => "ctx, ordered, output",
2072 _ => "ctx",
2073 };
2074
2075 let (action_type, action_value, return_form) = match action {
2077 CallbackAction::Skip => ("skip", String::new(), "dict"),
2078 CallbackAction::Continue => ("continue", String::new(), "dict"),
2079 CallbackAction::PreserveHtml => ("preserve_html", String::new(), "dict"),
2080 CallbackAction::Custom { output } => {
2081 let escaped = ruby_string_literal(output);
2082 ("custom", escaped, "dict")
2083 }
2084 CallbackAction::CustomTemplate { template, return_form } => {
2085 let interpolated = ruby_template_to_interpolation(template);
2086 let form = match return_form {
2087 TemplateReturnForm::Dict => "dict",
2088 TemplateReturnForm::BareString => "bare_string",
2089 };
2090 ("custom_template", format!("\"{interpolated}\""), form)
2091 }
2092 };
2093
2094 let rendered = crate::template_env::render(
2095 "ruby/visitor_method.jinja",
2096 minijinja::context! {
2097 method_name => method_name,
2098 params => params,
2099 action_type => action_type,
2100 action_value => action_value,
2101 return_form => return_form,
2102 },
2103 );
2104 for line in rendered.lines() {
2105 setup_lines.push(line.to_string());
2106 }
2107}
2108
2109fn is_file_path(s: &str) -> bool {
2114 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
2115 return false;
2116 }
2117
2118 let first = s.chars().next().unwrap_or('\0');
2119 if first.is_ascii_alphanumeric() || first == '_' {
2120 if let Some(slash_pos) = s.find('/') {
2121 if slash_pos > 0 {
2122 let after_slash = &s[slash_pos + 1..];
2123 if after_slash.contains('.') && !after_slash.is_empty() {
2124 return true;
2125 }
2126 }
2127 }
2128 }
2129
2130 false
2131}
2132
2133fn is_base64(s: &str) -> bool {
2136 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
2137 return false;
2138 }
2139
2140 if is_file_path(s) {
2141 return false;
2142 }
2143
2144 true
2145}