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