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