Skip to main content

alef_e2e/codegen/
ruby.rs

1//! Ruby e2e test generator using RSpec.
2//!
3//! Generates `e2e/ruby/Gemfile` and `spec/{category}_spec.rb` files from
4//! JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use 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
23/// Ruby e2e code generator.
24pub struct RubyCodegen;
25
26impl E2eCodegen for RubyCodegen {
27    fn generate(
28        &self,
29        groups: &[FixtureGroup],
30        e2e_config: &E2eConfig,
31        config: &ResolvedCrateConfig,
32    ) -> Result<Vec<GeneratedFile>> {
33        let lang = self.language_name();
34        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36        let mut files = Vec::new();
37
38        // Resolve call config with overrides.
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get(lang);
41        let module_path = overrides
42            .and_then(|o| o.module.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.module.clone());
45        let class_name = overrides.and_then(|o| o.class.as_ref()).cloned();
46        let options_type = overrides.and_then(|o| o.options_type.clone());
47        let empty_enum_fields = HashMap::new();
48        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
49        let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
50
51        // Resolve package config.
52        let ruby_pkg = e2e_config.resolve_package("ruby");
53        let gem_name = ruby_pkg
54            .as_ref()
55            .and_then(|p| p.name.as_ref())
56            .cloned()
57            .unwrap_or_else(|| config.name.replace('-', "_"));
58        let gem_path = ruby_pkg
59            .as_ref()
60            .and_then(|p| p.path.as_ref())
61            .cloned()
62            .unwrap_or_else(|| "../../packages/ruby".to_string());
63        let gem_version = ruby_pkg
64            .as_ref()
65            .and_then(|p| p.version.as_ref())
66            .cloned()
67            .or_else(|| config.resolved_version())
68            .unwrap_or_else(|| "0.1.0".to_string());
69
70        // Generate Gemfile.
71        files.push(GeneratedFile {
72            path: output_base.join("Gemfile"),
73            content: render_gemfile(&gem_name, &gem_path, &gem_version, e2e_config.dep_mode),
74            generated_header: false,
75        });
76
77        // Generate .rubocop.yaml for linting generated specs.
78        files.push(GeneratedFile {
79            path: output_base.join(".rubocop.yaml"),
80            content: render_rubocop_yaml(),
81            generated_header: false,
82        });
83
84        // Check if any fixture is an HTTP test (needs mock server bootstrap).
85        let has_http_fixtures = groups
86            .iter()
87            .flat_map(|g| g.fixtures.iter())
88            .any(|f| f.needs_mock_server());
89
90        // Check if any fixture uses file_path or bytes args (needs chdir to test_documents).
91        let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
92            let cc = e2e_config.resolve_call(f.call.as_deref());
93            cc.args
94                .iter()
95                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
96        });
97
98        // Always generate spec/spec_helper.rb when file-based or HTTP fixtures are present.
99        if has_file_fixtures || has_http_fixtures {
100            files.push(GeneratedFile {
101                path: output_base.join("spec").join("spec_helper.rb"),
102                content: render_spec_helper(has_file_fixtures, has_http_fixtures),
103                generated_header: true,
104            });
105        }
106
107        // Generate spec files per category.
108        let spec_base = output_base.join("spec");
109
110        for group in groups {
111            let active: Vec<&Fixture> = group
112                .fixtures
113                .iter()
114                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
115                .collect();
116
117            if active.is_empty() {
118                continue;
119            }
120
121            let field_resolver_pre = FieldResolver::new(
122                &e2e_config.fields,
123                &e2e_config.fields_optional,
124                &e2e_config.result_fields,
125                &e2e_config.fields_array,
126                &std::collections::HashSet::new(),
127            );
128            // Skip the entire file if no fixture in this category produces output.
129            let has_any_output = active.iter().any(|f| {
130                // HTTP tests always produce output.
131                if f.is_http_test() {
132                    return true;
133                }
134                let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
135                let has_not_error = f.assertions.iter().any(|a| a.assertion_type == "not_error");
136                expects_error || has_not_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
137            });
138            if !has_any_output {
139                continue;
140            }
141
142            let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
143            let field_resolver = FieldResolver::new(
144                &e2e_config.fields,
145                &e2e_config.fields_optional,
146                &e2e_config.result_fields,
147                &e2e_config.fields_array,
148                &std::collections::HashSet::new(),
149            );
150            let content = render_spec_file(
151                &group.category,
152                &active,
153                &module_path,
154                class_name.as_deref(),
155                &gem_name,
156                &field_resolver,
157                options_type.as_deref(),
158                enum_fields,
159                result_is_simple,
160                e2e_config,
161                has_file_fixtures || has_http_fixtures,
162            );
163            files.push(GeneratedFile {
164                path: spec_base.join(filename),
165                content,
166                generated_header: true,
167            });
168        }
169
170        Ok(files)
171    }
172
173    fn language_name(&self) -> &'static str {
174        "ruby"
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Rendering
180// ---------------------------------------------------------------------------
181
182fn render_gemfile(
183    gem_name: &str,
184    gem_path: &str,
185    gem_version: &str,
186    dep_mode: crate::config::DependencyMode,
187) -> String {
188    let gem_line = match dep_mode {
189        crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
190        crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
191    };
192    crate::template_env::render(
193        "ruby/Gemfile.jinja",
194        minijinja::context! {
195            gem_line => gem_line,
196            rspec => tv::gem::RSPEC_E2E,
197            rubocop => tv::gem::RUBOCOP_E2E,
198            rubocop_rspec => tv::gem::RUBOCOP_RSPEC_E2E,
199            faraday => tv::gem::FARADAY,
200        },
201    )
202}
203
204fn render_spec_helper(has_file_fixtures: bool, has_http_fixtures: bool) -> String {
205    let header = hash::header(CommentStyle::Hash);
206    let mut out = header;
207    out.push_str("# frozen_string_literal: true\n");
208
209    if has_file_fixtures {
210        out.push_str(
211            r#"
212# Change to the test_documents directory so that fixture file paths like
213# "pdf/fake_memo.pdf" resolve correctly when running rspec from e2e/ruby/.
214# spec_helper.rb lives in e2e/ruby/spec/; test_documents lives at the
215# repository root, three directories up: spec/ -> e2e/ruby/ -> e2e/ -> root.
216_test_documents = File.expand_path('../../../test_documents', __dir__)
217Dir.chdir(_test_documents) if Dir.exist?(_test_documents)
218"#,
219        );
220    }
221
222    if has_http_fixtures {
223        out.push_str(
224            r#"
225require 'open3'
226
227# Spawn the mock-server binary and set MOCK_SERVER_URL for all tests.
228RSpec.configure do |config|
229  config.before(:suite) do
230    bin = File.expand_path('../../rust/target/release/mock-server', __dir__)
231    fixtures_dir = File.expand_path('../../../fixtures', __dir__)
232    unless File.exist?(bin)
233      warn "mock-server binary not found at #{bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
234    end
235    stdin, stdout, _stderr, _wait = Open3.popen3(bin, fixtures_dir)
236    url = stdout.readline.strip.split('=', 2).last
237    ENV['MOCK_SERVER_URL'] = url
238    # Drain stdout in background.
239    Thread.new { stdout.read }
240    # Store stdin so we can close it on teardown.
241    @_mock_server_stdin = stdin
242  end
243
244  config.after(:suite) do
245    @_mock_server_stdin&.close
246  end
247end
248"#,
249        );
250    }
251
252    out
253}
254
255fn render_rubocop_yaml() -> String {
256    crate::template_env::render("ruby/rubocop.yml.jinja", minijinja::context! {})
257}
258
259#[allow(clippy::too_many_arguments)]
260fn render_spec_file(
261    category: &str,
262    fixtures: &[&Fixture],
263    module_path: &str,
264    class_name: Option<&str>,
265    gem_name: &str,
266    field_resolver: &FieldResolver,
267    options_type: Option<&str>,
268    enum_fields: &HashMap<String, String>,
269    result_is_simple: bool,
270    e2e_config: &E2eConfig,
271    needs_spec_helper: bool,
272) -> String {
273    // Resolve client_factory from ruby override.
274    let client_factory = e2e_config
275        .call
276        .overrides
277        .get("ruby")
278        .and_then(|o| o.client_factory.as_deref());
279
280    // Build requires list
281    let require_name = if module_path.is_empty() { gem_name } else { module_path };
282    let mut requires = vec![require_name.replace('-', "_"), "json".to_string()];
283
284    let has_http = fixtures.iter().any(|f| f.is_http_test());
285    if needs_spec_helper || has_http {
286        requires.push("spec_helper".to_string());
287    }
288
289    // Build the Ruby module/class qualifier for calls.
290    let call_receiver = class_name
291        .map(|s| s.to_string())
292        .unwrap_or_else(|| ruby_module_name(module_path));
293
294    // Check for array contains assertions
295    let has_array_contains = fixtures.iter().any(|fixture| {
296        fixture.assertions.iter().any(|a| {
297            matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
298                && a.field
299                    .as_deref()
300                    .is_some_and(|f| !f.is_empty() && field_resolver.is_array(field_resolver.resolve(f)))
301        })
302    });
303
304    // Build examples
305    let mut examples = Vec::new();
306    for fixture in fixtures {
307        if fixture.http.is_some() {
308            // HTTP example is handled separately (uses shared driver)
309            let mut out = String::new();
310            render_http_example(&mut out, fixture);
311            examples.push(out);
312        } else {
313            // Non-HTTP fixtures that have no usable assertions are pending
314            let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
315            let _has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
316            let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
317            if !expects_error && !has_usable {
318                let test_name = sanitize_ident(&fixture.id);
319                let description = fixture.description.replace('\'', "\\'");
320                let mut out = String::new();
321                out.push_str(&format!("  it '{test_name}: {description}' do\n"));
322                out.push_str("    skip 'Non-HTTP fixture cannot be tested via Net::HTTP'\n");
323                out.push_str("  end\n");
324                examples.push(out);
325            } else {
326                // Resolve per-fixture call config
327                let fixture_call = e2e_config.resolve_call(fixture.call.as_deref());
328                let fixture_call_overrides = fixture_call.overrides.get("ruby");
329                let raw_function_name = fixture_call_overrides
330                    .and_then(|o| o.function.as_ref())
331                    .cloned()
332                    .unwrap_or_else(|| fixture_call.function.clone());
333                // Magnus binds async Rust methods with an `_async` suffix; reflect that in
334                // the test so `client.chat(...)` resolves to the actual `chat_async` method.
335                let fixture_function_name = if fixture_call.r#async && !raw_function_name.ends_with("_async") {
336                    format!("{raw_function_name}_async")
337                } else {
338                    raw_function_name
339                };
340                let fixture_result_var = &fixture_call.result_var;
341                let fixture_args = &fixture_call.args;
342                let fixture_client_factory = fixture_call_overrides
343                    .and_then(|o| o.client_factory.as_deref())
344                    .or(client_factory);
345                let fixture_options_type = fixture_call_overrides
346                    .and_then(|o| o.options_type.as_deref())
347                    .or(options_type);
348
349                let fixture_extra_args: Vec<String> =
350                    fixture_call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
351                // Use per-fixture-call result_is_simple so per-call overrides like
352                // `speech` (returns bytes) take precedence over the top-level call default.
353                let fixture_result_is_simple =
354                    fixture_call.result_is_simple || fixture_call_overrides.is_some_and(|o| o.result_is_simple);
355                let example = render_example(
356                    fixture,
357                    &fixture_function_name,
358                    &call_receiver,
359                    fixture_result_var,
360                    fixture_args,
361                    field_resolver,
362                    fixture_options_type,
363                    enum_fields,
364                    fixture_result_is_simple,
365                    e2e_config,
366                    fixture_client_factory,
367                    &fixture_extra_args,
368                );
369                examples.push(example);
370            }
371        }
372    }
373
374    let header = hash::header(CommentStyle::Hash);
375    crate::template_env::render(
376        "ruby/test_file.jinja",
377        minijinja::context! {
378            category => category,
379            requires => requires,
380            has_array_contains => has_array_contains,
381            has_http => has_http,
382            examples => examples,
383            header => header,
384        },
385    )
386}
387
388/// Check if a fixture has at least one assertion that will produce an executable
389/// expect() call (not just a skip comment).
390fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
391    fixture.assertions.iter().any(|a| {
392        // not_error is implicit (call succeeding), error is handled separately.
393        if a.assertion_type == "not_error" || a.assertion_type == "error" {
394            return false;
395        }
396        // Check field validity.
397        if let Some(f) = &a.field {
398            if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
399                return false;
400            }
401            // When result_is_simple, skip non-content fields.
402            if result_is_simple {
403                let f_lower = f.to_lowercase();
404                if !f.is_empty()
405                    && f_lower != "content"
406                    && (f_lower.starts_with("metadata")
407                        || f_lower.starts_with("document")
408                        || f_lower.starts_with("structure"))
409                {
410                    return false;
411                }
412            }
413        }
414        true
415    })
416}
417
418// ---------------------------------------------------------------------------
419// HTTP test rendering — shared-driver integration
420// ---------------------------------------------------------------------------
421
422/// Thin renderer that emits RSpec `describe` + `it` blocks targeting a mock server
423/// via `Net::HTTP`. Satisfies [`client::TestClientRenderer`] so the shared
424/// [`client::http_call::render_http_test`] driver drives the call sequence.
425struct RubyTestClientRenderer;
426
427impl client::TestClientRenderer for RubyTestClientRenderer {
428    fn language_name(&self) -> &'static str {
429        "ruby"
430    }
431
432    /// Emit `describe '{fn_name}' do` + inner `it '{description}' do`.
433    ///
434    /// `fn_name` is the sanitised fixture id used as the describe label.
435    /// When `skip_reason` is `Some`, the inner `it` block gets a `skip` call so
436    /// the shared driver short-circuits before emitting any assertions.
437    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
438        let escaped_description = description.replace('\'', "\\'");
439        let rendered = crate::template_env::render(
440            "ruby/http_test.jinja",
441            minijinja::context! {
442                fn_name => fn_name,
443                description => escaped_description,
444                skip_reason => skip_reason,
445            },
446        );
447        out.push_str(&rendered);
448    }
449
450    /// Close the inner `it` block and the outer `describe` block.
451    fn render_test_close(&self, out: &mut String) {
452        let rendered = crate::template_env::render("ruby/http_test_close.jinja", minijinja::context! {});
453        out.push_str(&rendered);
454    }
455
456    /// Emit a `Net::HTTP` request to the mock server using the path from `ctx`.
457    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
458        let method = ctx.method.to_uppercase();
459        let method_class = http_method_class(&method);
460
461        let has_body = ctx
462            .body
463            .is_some_and(|b| !matches!(b, serde_json::Value::String(s) if s.is_empty()));
464
465        let ruby_body = if has_body {
466            json_to_ruby(ctx.body.unwrap())
467        } else {
468            String::new()
469        };
470
471        let headers: Vec<minijinja::Value> = ctx
472            .headers
473            .iter()
474            .filter(|(k, _)| {
475                // Skip Content-Type when already set from the body above.
476                !(has_body && k.to_lowercase() == "content-type")
477            })
478            .map(|(k, v)| {
479                minijinja::context! {
480                    key_literal => ruby_string_literal(k),
481                    value_literal => ruby_string_literal(v),
482                }
483            })
484            .collect();
485
486        let rendered = crate::template_env::render(
487            "ruby/http_request.jinja",
488            minijinja::context! {
489                method_class => method_class,
490                path => ctx.path,
491                has_body => has_body,
492                ruby_body => ruby_body,
493                headers => headers,
494                response_var => ctx.response_var,
495            },
496        );
497        out.push_str(&rendered);
498    }
499
500    /// Emit `expect(response.code.to_i).to eq(status)`.
501    ///
502    /// Net::HTTP returns the HTTP status as a `String`; `.to_i` converts it for
503    /// comparison with the integer literal from the fixture.
504    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
505        out.push_str(&format!("      expect({response_var}.code.to_i).to eq({status})\n"));
506    }
507
508    /// Emit a header assertion using `response[header_key]`.
509    ///
510    /// Handles the three special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
511    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
512        let header_key = name.to_lowercase();
513        let header_expr = format!("{response_var}[{}]", ruby_string_literal(&header_key));
514        let assertion = match expected {
515            "<<present>>" => {
516                format!("      expect({header_expr}).not_to be_nil\n")
517            }
518            "<<absent>>" => {
519                format!("      expect({header_expr}).to be_nil\n")
520            }
521            "<<uuid>>" => {
522                format!(
523                    "      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"
524                )
525            }
526            literal => {
527                let ruby_val = ruby_string_literal(literal);
528                format!("      expect({header_expr}).to eq({ruby_val})\n")
529            }
530        };
531        out.push_str(&assertion);
532    }
533
534    /// Emit a full JSON body equality assertion.
535    ///
536    /// Plain string bodies are compared as raw text; structured bodies are parsed
537    /// with `JSON.parse` and compared as Ruby Hash/Array values.
538    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
539        match expected {
540            serde_json::Value::String(s) => {
541                let ruby_val = ruby_string_literal(s);
542                out.push_str(&format!("      expect({response_var}.body).to eq({ruby_val})\n"));
543            }
544            _ => {
545                let ruby_val = json_to_ruby(expected);
546                out.push_str(&format!(
547                    "      _body = {response_var}.body && !{response_var}.body.empty? ? JSON.parse({response_var}.body) : nil\n"
548                ));
549                out.push_str(&format!("      expect(_body).to eq({ruby_val})\n"));
550            }
551        }
552    }
553
554    /// Emit partial body assertions: one `expect(_body[key]).to eq(val)` per field.
555    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
556        if let Some(obj) = expected.as_object() {
557            out.push_str(&format!("      _body = JSON.parse({response_var}.body)\n"));
558            for (key, val) in obj {
559                let ruby_key = ruby_string_literal(key);
560                let ruby_val = json_to_ruby(val);
561                out.push_str(&format!("      expect(_body[{ruby_key}]).to eq({ruby_val})\n"));
562            }
563        }
564    }
565
566    /// Emit validation-error assertions, checking each expected `msg` against the
567    /// parsed body's `errors` array.
568    fn render_assert_validation_errors(
569        &self,
570        out: &mut String,
571        response_var: &str,
572        errors: &[ValidationErrorExpectation],
573    ) {
574        for err in errors {
575            let msg_lit = ruby_string_literal(&err.msg);
576            out.push_str(&format!("      _body = JSON.parse({response_var}.body)\n"));
577            out.push_str("      _errors = _body['errors'] || []\n");
578            out.push_str(&format!(
579                "      expect(_errors.map {{ |e| e['msg'] }}).to include({msg_lit})\n"
580            ));
581        }
582    }
583}
584
585/// Render an RSpec example for an HTTP server test fixture via the shared driver.
586///
587/// Delegates to [`client::http_call::render_http_test`] after handling the one
588/// Ruby-specific pre-condition: HTTP 101 (WebSocket upgrade) cannot be exercised
589/// via `Net::HTTP` and is emitted as a pending `it` block directly.
590fn render_http_example(out: &mut String, fixture: &Fixture) {
591    // HTTP 101 (WebSocket upgrade) cannot be tested via Net::HTTP.
592    // Emit the skip block directly rather than pushing a skip directive through
593    // the shared driver, which would require a full `fixture.skip` entry.
594    if fixture
595        .http
596        .as_ref()
597        .is_some_and(|h| h.expected_response.status_code == 101)
598    {
599        if let Some(http) = fixture.http.as_ref() {
600            let description = fixture.description.replace('\'', "\\'");
601            let method = http.request.method.to_uppercase();
602            let path = &http.request.path;
603            let rendered = crate::template_env::render(
604                "ruby/http_101_skip.jinja",
605                minijinja::context! {
606                    method => method,
607                    path => path,
608                    description => description,
609                },
610            );
611            out.push_str(&rendered);
612        }
613        return;
614    }
615
616    client::http_call::render_http_test(out, &RubyTestClientRenderer, fixture);
617}
618
619/// Convert an uppercase HTTP method string to Ruby's Net::HTTP class name.
620/// Ruby uses title-cased names: Get, Post, Put, Delete, Patch, Head, Options, Trace.
621fn http_method_class(method: &str) -> String {
622    let mut chars = method.chars();
623    match chars.next() {
624        None => String::new(),
625        Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
626    }
627}
628
629// ---------------------------------------------------------------------------
630// Function-call test rendering
631// ---------------------------------------------------------------------------
632
633#[allow(clippy::too_many_arguments)]
634fn render_example(
635    fixture: &Fixture,
636    function_name: &str,
637    call_receiver: &str,
638    result_var: &str,
639    args: &[crate::config::ArgMapping],
640    field_resolver: &FieldResolver,
641    options_type: Option<&str>,
642    enum_fields: &HashMap<String, String>,
643    result_is_simple: bool,
644    e2e_config: &E2eConfig,
645    client_factory: Option<&str>,
646    extra_args: &[String],
647) -> String {
648    let test_name = sanitize_ident(&fixture.id);
649    let description = fixture.description.replace('\'', "\\'");
650    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
651    let fixture_id = fixture.id.clone();
652
653    let (mut setup_lines, args_str) = build_args_and_setup(
654        &fixture.input,
655        args,
656        call_receiver,
657        options_type,
658        enum_fields,
659        result_is_simple,
660        &fixture.id,
661    );
662
663    // Build visitor if present and add to setup
664    let mut visitor_arg = String::new();
665    if let Some(visitor_spec) = &fixture.visitor {
666        visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
667    }
668
669    let mut final_args = if visitor_arg.is_empty() {
670        args_str
671    } else if args_str.is_empty() {
672        visitor_arg
673    } else {
674        format!("{args_str}, {visitor_arg}")
675    };
676
677    // Append per-fixture extra_args (e.g. trailing `nil` for `list_files(purpose)`).
678    if !extra_args.is_empty() {
679        let extra_str = extra_args.join(", ");
680        if final_args.is_empty() {
681            final_args = extra_str;
682        } else {
683            final_args = format!("{final_args}, {extra_str}");
684        }
685    }
686
687    // When client_factory is configured, create a client instance and call methods on it.
688    let call_expr = if client_factory.is_some() {
689        format!("client.{function_name}({final_args})")
690    } else {
691        format!("{call_receiver}.{function_name}({final_args})")
692    };
693
694    // Check if any non-error assertion actually uses the result variable.
695    let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
696
697    // Render all assertions upfront into a string
698    let mut assertions_rendered = String::new();
699    for assertion in &fixture.assertions {
700        render_assertion(
701            &mut assertions_rendered,
702            assertion,
703            result_var,
704            field_resolver,
705            result_is_simple,
706            e2e_config,
707        );
708    }
709
710    let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
711    let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
712    crate::template_env::render(
713        "ruby/test_function.jinja",
714        minijinja::context! {
715            test_name => test_name,
716            description => description,
717            expects_error => expects_error,
718            setup_lines => setup_lines,
719            call_expr => call_expr,
720            result_var => result_var,
721            assertions_rendered => assertions_rendered,
722            has_usable => has_usable,
723            client_factory => client_factory,
724            fixture_id => fixture_id,
725            call_receiver => call_receiver,
726            has_mock => has_mock,
727            api_key_var => api_key_var,
728        },
729    )
730}
731
732/// Build setup lines (e.g. handle creation) and the argument list for the function call.
733///
734/// Returns `(setup_lines, args_string)`.
735/// Emit Ruby batch item constructors for BatchBytesItem or BatchFileItem arrays.
736fn emit_ruby_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
737    if let Some(items) = arr.as_array() {
738        let item_strs: Vec<String> = items
739            .iter()
740            .filter_map(|item| {
741                if let Some(obj) = item.as_object() {
742                    match elem_type {
743                        "BatchBytesItem" => {
744                            let content = obj.get("content").and_then(|v| v.as_array());
745                            let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
746                            let config = obj.get("config");
747                            let content_code = if let Some(arr) = content {
748                                let bytes: Vec<String> =
749                                    arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
750                                // Pass as Ruby array - Magnus will convert Array<u8> to Vec<u8>
751                                format!("[{}]", bytes.join(", "))
752                            } else {
753                                "[]".to_string()
754                            };
755                            let config_arg = if let Some(cfg) = config {
756                                if cfg.is_null() {
757                                    "nil".to_string()
758                                } else {
759                                    json_to_ruby(cfg)
760                                }
761                            } else {
762                                "nil".to_string()
763                            };
764                            Some(format!(
765                                "Kreuzberg::{}.new(content: {}, mime_type: \"{}\", config: {})",
766                                elem_type, content_code, mime_type, config_arg
767                            ))
768                        }
769                        "BatchFileItem" => {
770                            let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
771                            let config = obj.get("config");
772                            let config_arg = if let Some(cfg) = config {
773                                if cfg.is_null() {
774                                    "nil".to_string()
775                                } else {
776                                    json_to_ruby(cfg)
777                                }
778                            } else {
779                                "nil".to_string()
780                            };
781                            Some(format!(
782                                "Kreuzberg::{}.new(path: \"{}\", config: {})",
783                                elem_type, path, config_arg
784                            ))
785                        }
786                        _ => None,
787                    }
788                } else {
789                    None
790                }
791            })
792            .collect();
793        format!("[{}]", item_strs.join(", "))
794    } else {
795        "[]".to_string()
796    }
797}
798
799fn build_args_and_setup(
800    input: &serde_json::Value,
801    args: &[crate::config::ArgMapping],
802    call_receiver: &str,
803    options_type: Option<&str>,
804    enum_fields: &HashMap<String, String>,
805    result_is_simple: bool,
806    fixture_id: &str,
807) -> (Vec<String>, String) {
808    if args.is_empty() {
809        // No args config: pass the whole input only when it's non-empty.
810        // Functions with no parameters have empty input and must be called
811        // with no arguments — not with `{}` or `nil`.
812        let is_empty_input = match input {
813            serde_json::Value::Null => true,
814            serde_json::Value::Object(m) => m.is_empty(),
815            _ => false,
816        };
817        if is_empty_input {
818            return (Vec::new(), String::new());
819        }
820        return (Vec::new(), json_to_ruby(input));
821    }
822
823    let mut setup_lines: Vec<String> = Vec::new();
824    let mut parts: Vec<String> = Vec::new();
825    // Track optional args that were skipped; if a later arg is emitted we must back-fill nil
826    // to preserve positional correctness (e.g. extract_file(path, nil, config)).
827    let mut skipped_optional_count: usize = 0;
828
829    for arg in args {
830        if arg.arg_type == "mock_url" {
831            // Flush any pending nil placeholders for skipped optionals before this positional arg.
832            for _ in 0..skipped_optional_count {
833                parts.push("nil".to_string());
834            }
835            skipped_optional_count = 0;
836            setup_lines.push(format!(
837                "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
838                arg.name,
839            ));
840            parts.push(arg.name.clone());
841            continue;
842        }
843
844        // Handle bytes arguments: load from file if needed
845        if arg.arg_type == "bytes" {
846            // Flush any pending nil placeholders for skipped optionals before this positional arg.
847            for _ in 0..skipped_optional_count {
848                parts.push("nil".to_string());
849            }
850            skipped_optional_count = 0;
851            let resolved = resolve_field(input, &arg.field);
852            if let Some(s) = resolved.as_str() {
853                if is_file_path(s) {
854                    // File path: load with File.read and convert to bytes array
855                    setup_lines.push(format!("{} = File.read(\"{}\").bytes", arg.name, s));
856                } else if is_base64(s) {
857                    // Base64: decode it
858                    setup_lines.push(format!("{} = Base64.decode64(\"{}\").bytes", arg.name, s));
859                } else {
860                    // Inline text: encode to binary and convert to bytes array
861                    let escaped = ruby_string_literal(s);
862                    setup_lines.push(format!("{} = {}.b.bytes", arg.name, escaped));
863                }
864                parts.push(arg.name.clone());
865            } else {
866                parts.push("nil".to_string());
867            }
868            continue;
869        }
870
871        // Handle file_path arguments: pass the path string as-is
872        if arg.arg_type == "file_path" {
873            // Flush any pending nil placeholders for skipped optionals before this positional arg.
874            for _ in 0..skipped_optional_count {
875                parts.push("nil".to_string());
876            }
877            skipped_optional_count = 0;
878            let resolved = resolve_field(input, &arg.field);
879            if let Some(s) = resolved.as_str() {
880                let escaped = ruby_string_literal(s);
881                parts.push(escaped);
882            } else if arg.optional {
883                skipped_optional_count += 1;
884                continue;
885            } else {
886                parts.push("''".to_string());
887            }
888            continue;
889        }
890
891        if arg.arg_type == "handle" {
892            // Flush any pending nil placeholders for skipped optionals before this positional arg.
893            for _ in 0..skipped_optional_count {
894                parts.push("nil".to_string());
895            }
896            skipped_optional_count = 0;
897            // Generate a create_engine (or equivalent) call and pass the variable.
898            let constructor_name = format!("create_{}", arg.name.to_snake_case());
899            let config_value = resolve_field(input, &arg.field);
900            if config_value.is_null()
901                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
902            {
903                setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
904            } else {
905                let literal = json_to_ruby(config_value);
906                let name = &arg.name;
907                setup_lines.push(format!("{name}_config = {literal}"));
908                setup_lines.push(format!(
909                    "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
910                    arg.name,
911                    name = name,
912                ));
913            }
914            parts.push(arg.name.clone());
915            continue;
916        }
917
918        let resolved = resolve_field(input, &arg.field);
919        let val = if resolved.is_null() { None } else { Some(resolved) };
920        match val {
921            None | Some(serde_json::Value::Null) if arg.optional => {
922                // Optional arg with no fixture value: defer; emit nil only if a later arg is present.
923                skipped_optional_count += 1;
924                continue;
925            }
926            None | Some(serde_json::Value::Null) => {
927                // Required arg with no fixture value: flush deferred nils, then pass a default.
928                for _ in 0..skipped_optional_count {
929                    parts.push("nil".to_string());
930                }
931                skipped_optional_count = 0;
932                let default_val = match arg.arg_type.as_str() {
933                    "string" => "''".to_string(),
934                    "int" | "integer" => "0".to_string(),
935                    "float" | "number" => "0.0".to_string(),
936                    "bool" | "boolean" => "false".to_string(),
937                    _ => "nil".to_string(),
938                };
939                parts.push(default_val);
940            }
941            Some(v) => {
942                // Flush deferred nil placeholders for skipped optional args that precede this one.
943                for _ in 0..skipped_optional_count {
944                    parts.push("nil".to_string());
945                }
946                skipped_optional_count = 0;
947                // For json_object args with options_type, construct a typed options object.
948                // When result_is_simple, the binding accepts a plain Hash (no wrapper class).
949                if arg.arg_type == "json_object" && !v.is_null() {
950                    // Check for batch item arrays (element_type set to BatchBytesItem/BatchFileItem)
951                    if let Some(elem_type) = &arg.element_type {
952                        if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
953                            parts.push(emit_ruby_batch_item_array(v, elem_type));
954                            continue;
955                        }
956                    }
957                    // Otherwise handle regular options_type objects
958                    if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
959                        let kwargs: Vec<String> = obj
960                            .iter()
961                            .map(|(k, vv)| {
962                                let snake_key = k.to_snake_case();
963                                let rb_val = if enum_fields.contains_key(k) {
964                                    if let Some(s) = vv.as_str() {
965                                        let snake_val = s.to_snake_case();
966                                        format!("'{snake_val}'")
967                                    } else {
968                                        json_to_ruby(vv)
969                                    }
970                                } else {
971                                    json_to_ruby(vv)
972                                };
973                                format!("{snake_key}: {rb_val}")
974                            })
975                            .collect();
976                        if result_is_simple {
977                            parts.push(format!("{{{}}}", kwargs.join(", ")));
978                        } else {
979                            parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
980                        }
981                        continue;
982                    }
983                }
984                parts.push(json_to_ruby(v));
985            }
986        }
987    }
988
989    (setup_lines, parts.join(", "))
990}
991
992fn render_assertion(
993    out: &mut String,
994    assertion: &Assertion,
995    result_var: &str,
996    field_resolver: &FieldResolver,
997    result_is_simple: bool,
998    e2e_config: &E2eConfig,
999) {
1000    // For simple-result methods (e.g. `speech` returning bytes), every field-based
1001    // assertion targets the result itself — there's no struct to access. Drop
1002    // length-only assertions onto the result directly and skip anything else.
1003    if result_is_simple {
1004        if let Some(f) = &assertion.field {
1005            if !f.is_empty() {
1006                match assertion.assertion_type.as_str() {
1007                    "not_empty" => {
1008                        out.push_str(&format!("    expect({result_var}.to_s).not_to be_empty\n"));
1009                        return;
1010                    }
1011                    "is_empty" => {
1012                        out.push_str(&format!("    expect({result_var}.to_s).to be_empty\n"));
1013                        return;
1014                    }
1015                    "count_equals" => {
1016                        if let Some(val) = &assertion.value {
1017                            let rb_val = json_to_ruby(val);
1018                            out.push_str(&format!("    expect({result_var}.length).to eq({rb_val})\n"));
1019                        }
1020                        return;
1021                    }
1022                    "count_min" => {
1023                        if let Some(val) = &assertion.value {
1024                            let rb_val = json_to_ruby(val);
1025                            out.push_str(&format!("    expect({result_var}.length).to be >= {rb_val}\n"));
1026                        }
1027                        return;
1028                    }
1029                    _ => {
1030                        out.push_str(&format!(
1031                            "    # skipped: field '{f}' not applicable for simple result type\n"
1032                        ));
1033                        return;
1034                    }
1035                }
1036            }
1037        }
1038    }
1039    // Handle synthetic / derived fields before the is_valid_for_result check
1040    // so they are never treated as struct attribute accesses on the result.
1041    if let Some(f) = &assertion.field {
1042        match f.as_str() {
1043            "chunks_have_content" => {
1044                let pred = format!("({result_var}.chunks || []).all? {{ |c| c.content && !c.content.empty? }}");
1045                match assertion.assertion_type.as_str() {
1046                    "is_true" => {
1047                        out.push_str(&format!("    expect({pred}).to be(true)\n"));
1048                    }
1049                    "is_false" => {
1050                        out.push_str(&format!("    expect({pred}).to be(false)\n"));
1051                    }
1052                    _ => {
1053                        out.push_str(&format!(
1054                            "    # skipped: unsupported assertion type on synthetic field '{f}'\n"
1055                        ));
1056                    }
1057                }
1058                return;
1059            }
1060            "chunks_have_embeddings" => {
1061                let pred =
1062                    format!("({result_var}.chunks || []).all? {{ |c| !c.embedding.nil? && !c.embedding.empty? }}");
1063                match assertion.assertion_type.as_str() {
1064                    "is_true" => {
1065                        out.push_str(&format!("    expect({pred}).to be(true)\n"));
1066                    }
1067                    "is_false" => {
1068                        out.push_str(&format!("    expect({pred}).to be(false)\n"));
1069                    }
1070                    _ => {
1071                        out.push_str(&format!(
1072                            "    # skipped: unsupported assertion type on synthetic field '{f}'\n"
1073                        ));
1074                    }
1075                }
1076                return;
1077            }
1078            // ---- EmbedResponse virtual fields ----
1079            // embed_texts returns Array<Array<Float>> in Ruby — no wrapper struct.
1080            // result_var is the embedding matrix; use it directly.
1081            "embeddings" => {
1082                match assertion.assertion_type.as_str() {
1083                    "count_equals" => {
1084                        if let Some(val) = &assertion.value {
1085                            let rb_val = json_to_ruby(val);
1086                            out.push_str(&format!("    expect({result_var}.length).to eq({rb_val})\n"));
1087                        }
1088                    }
1089                    "count_min" => {
1090                        if let Some(val) = &assertion.value {
1091                            let rb_val = json_to_ruby(val);
1092                            out.push_str(&format!("    expect({result_var}.length).to be >= {rb_val}\n"));
1093                        }
1094                    }
1095                    "not_empty" => {
1096                        out.push_str(&format!("    expect({result_var}).not_to be_empty\n"));
1097                    }
1098                    "is_empty" => {
1099                        out.push_str(&format!("    expect({result_var}).to be_empty\n"));
1100                    }
1101                    _ => {
1102                        out.push_str("    # skipped: unsupported assertion type on synthetic field 'embeddings'\n");
1103                    }
1104                }
1105                return;
1106            }
1107            "embedding_dimensions" => {
1108                let expr = format!("({result_var}.empty? ? 0 : {result_var}[0].length)");
1109                match assertion.assertion_type.as_str() {
1110                    "equals" => {
1111                        if let Some(val) = &assertion.value {
1112                            let rb_val = json_to_ruby(val);
1113                            out.push_str(&format!("    expect({expr}).to eq({rb_val})\n"));
1114                        }
1115                    }
1116                    "greater_than" => {
1117                        if let Some(val) = &assertion.value {
1118                            let rb_val = json_to_ruby(val);
1119                            out.push_str(&format!("    expect({expr}).to be > {rb_val}\n"));
1120                        }
1121                    }
1122                    _ => {
1123                        out.push_str(
1124                            "    # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n",
1125                        );
1126                    }
1127                }
1128                return;
1129            }
1130            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1131                let pred = match f.as_str() {
1132                    "embeddings_valid" => {
1133                        format!("{result_var}.all? {{ |e| !e.empty? }}")
1134                    }
1135                    "embeddings_finite" => {
1136                        format!("{result_var}.all? {{ |e| e.all? {{ |v| v.finite? }} }}")
1137                    }
1138                    "embeddings_non_zero" => {
1139                        format!("{result_var}.all? {{ |e| e.any? {{ |v| v != 0.0 }} }}")
1140                    }
1141                    "embeddings_normalized" => {
1142                        format!("{result_var}.all? {{ |e| n = e.sum {{ |v| v * v }}; (n - 1.0).abs < 1e-3 }}")
1143                    }
1144                    _ => unreachable!(),
1145                };
1146                match assertion.assertion_type.as_str() {
1147                    "is_true" => {
1148                        out.push_str(&format!("    expect({pred}).to be(true)\n"));
1149                    }
1150                    "is_false" => {
1151                        out.push_str(&format!("    expect({pred}).to be(false)\n"));
1152                    }
1153                    _ => {
1154                        out.push_str(&format!(
1155                            "    # skipped: unsupported assertion type on synthetic field '{f}'\n"
1156                        ));
1157                    }
1158                }
1159                return;
1160            }
1161            // ---- keywords / keywords_count ----
1162            // Ruby ExtractionResult does not expose extracted_keywords; skip.
1163            "keywords" | "keywords_count" => {
1164                out.push_str(&format!(
1165                    "    # skipped: field '{f}' not available on Ruby ExtractionResult\n"
1166                ));
1167                return;
1168            }
1169            _ => {}
1170        }
1171    }
1172
1173    // Skip assertions on fields that don't exist on the result type.
1174    if let Some(f) = &assertion.field {
1175        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1176            out.push_str(&format!("    # skipped: field '{f}' not available on result type\n"));
1177            return;
1178        }
1179    }
1180
1181    // When result_is_simple, skip assertions that reference non-content fields.
1182    if result_is_simple {
1183        if let Some(f) = &assertion.field {
1184            let f_lower = f.to_lowercase();
1185            if !f.is_empty()
1186                && f_lower != "content"
1187                && (f_lower.starts_with("metadata")
1188                    || f_lower.starts_with("document")
1189                    || f_lower.starts_with("structure"))
1190            {
1191                return;
1192            }
1193        }
1194    }
1195
1196    // result_is_simple: treat the result itself as the content string, but only
1197    // when there is no explicit field (or the field is "content"). Count/length
1198    // assertions on named fields (e.g. "warnings") must still walk the field path.
1199    let field_expr = match &assertion.field {
1200        Some(f) if !f.is_empty() && (!result_is_simple || !f.eq_ignore_ascii_case("content")) => {
1201            field_resolver.accessor(f, "ruby", result_var)
1202        }
1203        _ => result_var.to_string(),
1204    };
1205
1206    // For string equality, strip trailing whitespace to handle trailing newlines
1207    // from the converter. Ruby enum fields (Magnus binds Rust enums as Symbols),
1208    // are coerced to String via .to_s so `eq("stop")` matches `:stop`.
1209    let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1210        let resolved = field_resolver.resolve(f);
1211        e2e_config.fields_enum.contains(f) || e2e_config.fields_enum.contains(resolved)
1212    });
1213    let stripped_field_expr = if result_is_simple {
1214        format!("{field_expr}.to_s.strip")
1215    } else if field_is_enum {
1216        format!("{field_expr}.to_s")
1217    } else {
1218        field_expr.clone()
1219    };
1220
1221    // Detect whether the assertion field resolves to an array type so that
1222    // contains assertions can iterate items instead of calling .to_s on the array.
1223    let field_is_array = assertion
1224        .field
1225        .as_deref()
1226        .filter(|f| !f.is_empty())
1227        .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1228
1229    match assertion.assertion_type.as_str() {
1230        "equals" => {
1231            if let Some(expected) = &assertion.value {
1232                let is_boolean_val = expected.as_bool().is_some();
1233                let bool_val = expected
1234                    .as_bool()
1235                    .map(|b| if b { "true" } else { "false" })
1236                    .unwrap_or("");
1237                let rb_val = json_to_ruby(expected);
1238
1239                let rendered = crate::template_env::render(
1240                    "ruby/assertion.jinja",
1241                    minijinja::context! {
1242                        assertion_type => "equals",
1243                        stripped_field_expr => stripped_field_expr.clone(),
1244                        is_boolean_val => is_boolean_val,
1245                        bool_val => bool_val,
1246                        expected_val => rb_val,
1247                    },
1248                );
1249                out.push_str(&rendered);
1250            }
1251        }
1252        "contains" => {
1253            if let Some(expected) = &assertion.value {
1254                let rb_val = json_to_ruby(expected);
1255                let rendered = crate::template_env::render(
1256                    "ruby/assertion.jinja",
1257                    minijinja::context! {
1258                        assertion_type => "contains",
1259                        field_expr => field_expr.clone(),
1260                        field_is_array => field_is_array && expected.is_string(),
1261                        expected_val => rb_val,
1262                    },
1263                );
1264                out.push_str(&rendered);
1265            }
1266        }
1267        "contains_all" => {
1268            if let Some(values) = &assertion.values {
1269                let values_list: Vec<String> = values.iter().map(json_to_ruby).collect();
1270                let rendered = crate::template_env::render(
1271                    "ruby/assertion.jinja",
1272                    minijinja::context! {
1273                        assertion_type => "contains_all",
1274                        field_expr => field_expr.clone(),
1275                        field_is_array => field_is_array,
1276                        values_list => values_list,
1277                    },
1278                );
1279                out.push_str(&rendered);
1280            }
1281        }
1282        "not_contains" => {
1283            if let Some(expected) = &assertion.value {
1284                let rb_val = json_to_ruby(expected);
1285                let rendered = crate::template_env::render(
1286                    "ruby/assertion.jinja",
1287                    minijinja::context! {
1288                        assertion_type => "not_contains",
1289                        field_expr => field_expr.clone(),
1290                        field_is_array => field_is_array && expected.is_string(),
1291                        expected_val => rb_val,
1292                    },
1293                );
1294                out.push_str(&rendered);
1295            }
1296        }
1297        "not_empty" => {
1298            let rendered = crate::template_env::render(
1299                "ruby/assertion.jinja",
1300                minijinja::context! {
1301                    assertion_type => "not_empty",
1302                    field_expr => field_expr.clone(),
1303                },
1304            );
1305            out.push_str(&rendered);
1306        }
1307        "is_empty" => {
1308            let rendered = crate::template_env::render(
1309                "ruby/assertion.jinja",
1310                minijinja::context! {
1311                    assertion_type => "is_empty",
1312                    field_expr => field_expr.clone(),
1313                },
1314            );
1315            out.push_str(&rendered);
1316        }
1317        "contains_any" => {
1318            if let Some(values) = &assertion.values {
1319                let items: Vec<String> = values.iter().map(json_to_ruby).collect();
1320                let rendered = crate::template_env::render(
1321                    "ruby/assertion.jinja",
1322                    minijinja::context! {
1323                        assertion_type => "contains_any",
1324                        field_expr => field_expr.clone(),
1325                        values_list => items,
1326                    },
1327                );
1328                out.push_str(&rendered);
1329            }
1330        }
1331        "greater_than" => {
1332            if let Some(val) = &assertion.value {
1333                let rb_val = json_to_ruby(val);
1334                let rendered = crate::template_env::render(
1335                    "ruby/assertion.jinja",
1336                    minijinja::context! {
1337                        assertion_type => "greater_than",
1338                        field_expr => field_expr.clone(),
1339                        expected_val => rb_val,
1340                    },
1341                );
1342                out.push_str(&rendered);
1343            }
1344        }
1345        "less_than" => {
1346            if let Some(val) = &assertion.value {
1347                let rb_val = json_to_ruby(val);
1348                let rendered = crate::template_env::render(
1349                    "ruby/assertion.jinja",
1350                    minijinja::context! {
1351                        assertion_type => "less_than",
1352                        field_expr => field_expr.clone(),
1353                        expected_val => rb_val,
1354                    },
1355                );
1356                out.push_str(&rendered);
1357            }
1358        }
1359        "greater_than_or_equal" => {
1360            if let Some(val) = &assertion.value {
1361                let rb_val = json_to_ruby(val);
1362                let rendered = crate::template_env::render(
1363                    "ruby/assertion.jinja",
1364                    minijinja::context! {
1365                        assertion_type => "greater_than_or_equal",
1366                        field_expr => field_expr.clone(),
1367                        expected_val => rb_val,
1368                    },
1369                );
1370                out.push_str(&rendered);
1371            }
1372        }
1373        "less_than_or_equal" => {
1374            if let Some(val) = &assertion.value {
1375                let rb_val = json_to_ruby(val);
1376                let rendered = crate::template_env::render(
1377                    "ruby/assertion.jinja",
1378                    minijinja::context! {
1379                        assertion_type => "less_than_or_equal",
1380                        field_expr => field_expr.clone(),
1381                        expected_val => rb_val,
1382                    },
1383                );
1384                out.push_str(&rendered);
1385            }
1386        }
1387        "starts_with" => {
1388            if let Some(expected) = &assertion.value {
1389                let rb_val = json_to_ruby(expected);
1390                let rendered = crate::template_env::render(
1391                    "ruby/assertion.jinja",
1392                    minijinja::context! {
1393                        assertion_type => "starts_with",
1394                        field_expr => field_expr.clone(),
1395                        expected_val => rb_val,
1396                    },
1397                );
1398                out.push_str(&rendered);
1399            }
1400        }
1401        "ends_with" => {
1402            if let Some(expected) = &assertion.value {
1403                let rb_val = json_to_ruby(expected);
1404                let rendered = crate::template_env::render(
1405                    "ruby/assertion.jinja",
1406                    minijinja::context! {
1407                        assertion_type => "ends_with",
1408                        field_expr => field_expr.clone(),
1409                        expected_val => rb_val,
1410                    },
1411                );
1412                out.push_str(&rendered);
1413            }
1414        }
1415        "min_length" | "max_length" | "count_min" | "count_equals" => {
1416            if let Some(val) = &assertion.value {
1417                if let Some(n) = val.as_u64() {
1418                    let rendered = crate::template_env::render(
1419                        "ruby/assertion.jinja",
1420                        minijinja::context! {
1421                            assertion_type => assertion.assertion_type.as_str(),
1422                            field_expr => field_expr.clone(),
1423                            check_n => n,
1424                        },
1425                    );
1426                    out.push_str(&rendered);
1427                }
1428            }
1429        }
1430        "is_true" => {
1431            let rendered = crate::template_env::render(
1432                "ruby/assertion.jinja",
1433                minijinja::context! {
1434                    assertion_type => "is_true",
1435                    field_expr => field_expr.clone(),
1436                },
1437            );
1438            out.push_str(&rendered);
1439        }
1440        "is_false" => {
1441            let rendered = crate::template_env::render(
1442                "ruby/assertion.jinja",
1443                minijinja::context! {
1444                    assertion_type => "is_false",
1445                    field_expr => field_expr.clone(),
1446                },
1447            );
1448            out.push_str(&rendered);
1449        }
1450        "method_result" => {
1451            if let Some(method_name) = &assertion.method {
1452                // Derive call_receiver for module-level helper calls.
1453                let lang = "ruby";
1454                let call = &e2e_config.call;
1455                let overrides = call.overrides.get(lang);
1456                let module_path = overrides
1457                    .and_then(|o| o.module.as_ref())
1458                    .cloned()
1459                    .unwrap_or_else(|| call.module.clone());
1460                let call_receiver = ruby_module_name(&module_path);
1461
1462                let call_expr =
1463                    build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
1464                let check = assertion.check.as_deref().unwrap_or("is_true");
1465
1466                let (check_val_str, is_boolean_check, bool_check_val, check_n_val) = match check {
1467                    "equals" => {
1468                        if let Some(val) = &assertion.value {
1469                            let is_bool = val.as_bool().is_some();
1470                            let bool_str = val.as_bool().map(|b| if b { "true" } else { "false" }).unwrap_or("");
1471                            let rb_val = json_to_ruby(val);
1472                            (rb_val, is_bool, bool_str.to_string(), 0)
1473                        } else {
1474                            (String::new(), false, String::new(), 0)
1475                        }
1476                    }
1477                    "greater_than_or_equal" => {
1478                        if let Some(val) = &assertion.value {
1479                            (json_to_ruby(val), false, String::new(), 0)
1480                        } else {
1481                            (String::new(), false, String::new(), 0)
1482                        }
1483                    }
1484                    "count_min" => {
1485                        if let Some(val) = &assertion.value {
1486                            let n = val.as_u64().unwrap_or(0);
1487                            (String::new(), false, String::new(), n)
1488                        } else {
1489                            (String::new(), false, String::new(), 0)
1490                        }
1491                    }
1492                    "contains" => {
1493                        if let Some(val) = &assertion.value {
1494                            (json_to_ruby(val), false, String::new(), 0)
1495                        } else {
1496                            (String::new(), false, String::new(), 0)
1497                        }
1498                    }
1499                    _ => (String::new(), false, String::new(), 0),
1500                };
1501
1502                let rendered = crate::template_env::render(
1503                    "ruby/assertion.jinja",
1504                    minijinja::context! {
1505                        assertion_type => "method_result",
1506                        call_expr => call_expr,
1507                        check => check,
1508                        check_val => check_val_str,
1509                        is_boolean_check => is_boolean_check,
1510                        bool_check_val => bool_check_val,
1511                        check_n => check_n_val,
1512                    },
1513                );
1514                out.push_str(&rendered);
1515            } else {
1516                panic!("Ruby e2e generator: method_result assertion missing 'method' field");
1517            }
1518        }
1519        "matches_regex" => {
1520            if let Some(expected) = &assertion.value {
1521                let rb_val = json_to_ruby(expected);
1522                let rendered = crate::template_env::render(
1523                    "ruby/assertion.jinja",
1524                    minijinja::context! {
1525                        assertion_type => "matches_regex",
1526                        field_expr => field_expr.clone(),
1527                        expected_val => rb_val,
1528                    },
1529                );
1530                out.push_str(&rendered);
1531            }
1532        }
1533        "not_error" => {
1534            // Already handled by the call succeeding without exception.
1535        }
1536        "error" => {
1537            // Handled at the example level.
1538        }
1539        other => {
1540            panic!("Ruby e2e generator: unsupported assertion type: {other}");
1541        }
1542    }
1543}
1544
1545/// Build a Ruby call expression for a `method_result` assertion on a tree-sitter Tree.
1546/// Maps method names to the appropriate Ruby method or module-function calls.
1547fn build_ruby_method_call(
1548    call_receiver: &str,
1549    result_var: &str,
1550    method_name: &str,
1551    args: Option<&serde_json::Value>,
1552) -> String {
1553    match method_name {
1554        "root_child_count" => format!("{result_var}.root_node.child_count"),
1555        "root_node_type" => format!("{result_var}.root_node.type"),
1556        "named_children_count" => format!("{result_var}.root_node.named_child_count"),
1557        "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
1558        "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
1559        "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
1560        "contains_node_type" => {
1561            let node_type = args
1562                .and_then(|a| a.get("node_type"))
1563                .and_then(|v| v.as_str())
1564                .unwrap_or("");
1565            format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
1566        }
1567        "find_nodes_by_type" => {
1568            let node_type = args
1569                .and_then(|a| a.get("node_type"))
1570                .and_then(|v| v.as_str())
1571                .unwrap_or("");
1572            format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
1573        }
1574        "run_query" => {
1575            let query_source = args
1576                .and_then(|a| a.get("query_source"))
1577                .and_then(|v| v.as_str())
1578                .unwrap_or("");
1579            let language = args
1580                .and_then(|a| a.get("language"))
1581                .and_then(|v| v.as_str())
1582                .unwrap_or("");
1583            format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1584        }
1585        _ => format!("{result_var}.{method_name}"),
1586    }
1587}
1588
1589/// Convert a module path (e.g., "html_to_markdown") to Ruby PascalCase module name
1590/// (e.g., "HtmlToMarkdown").
1591fn ruby_module_name(module_path: &str) -> String {
1592    use heck::ToUpperCamelCase;
1593    module_path.to_upper_camel_case()
1594}
1595
1596/// Convert a `serde_json::Value` to a Ruby literal string, preferring single quotes.
1597fn json_to_ruby(value: &serde_json::Value) -> String {
1598    match value {
1599        serde_json::Value::String(s) => ruby_string_literal(s),
1600        serde_json::Value::Bool(true) => "true".to_string(),
1601        serde_json::Value::Bool(false) => "false".to_string(),
1602        serde_json::Value::Number(n) => n.to_string(),
1603        serde_json::Value::Null => "nil".to_string(),
1604        serde_json::Value::Array(arr) => {
1605            let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
1606            format!("[{}]", items.join(", "))
1607        }
1608        serde_json::Value::Object(map) => {
1609            let items: Vec<String> = map
1610                .iter()
1611                .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
1612                .collect();
1613            format!("{{ {} }}", items.join(", "))
1614        }
1615    }
1616}
1617
1618// ---------------------------------------------------------------------------
1619// Visitor generation
1620// ---------------------------------------------------------------------------
1621
1622/// Build a Ruby visitor object and add setup lines. Returns the visitor expression.
1623fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1624    setup_lines.push("visitor = Class.new do".to_string());
1625    for (method_name, action) in &visitor_spec.callbacks {
1626        emit_ruby_visitor_method(setup_lines, method_name, action);
1627    }
1628    setup_lines.push("end.new".to_string());
1629    "visitor".to_string()
1630}
1631
1632/// Emit a Ruby visitor method for a callback action.
1633fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1634    let params = match method_name {
1635        "visit_link" => "ctx, href, text, title",
1636        "visit_image" => "ctx, src, alt, title",
1637        "visit_heading" => "ctx, level, text, id",
1638        "visit_code_block" => "ctx, lang, code",
1639        "visit_code_inline"
1640        | "visit_strong"
1641        | "visit_emphasis"
1642        | "visit_strikethrough"
1643        | "visit_underline"
1644        | "visit_subscript"
1645        | "visit_superscript"
1646        | "visit_mark"
1647        | "visit_button"
1648        | "visit_summary"
1649        | "visit_figcaption"
1650        | "visit_definition_term"
1651        | "visit_definition_description" => "ctx, text",
1652        "visit_text" => "ctx, text",
1653        "visit_list_item" => "ctx, ordered, marker, text",
1654        "visit_blockquote" => "ctx, content, depth",
1655        "visit_table_row" => "ctx, cells, is_header",
1656        "visit_custom_element" => "ctx, tag_name, html",
1657        "visit_form" => "ctx, action_url, method",
1658        "visit_input" => "ctx, input_type, name, value",
1659        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1660        "visit_details" => "ctx, is_open",
1661        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1662        "visit_list_start" => "ctx, ordered",
1663        "visit_list_end" => "ctx, ordered, output",
1664        _ => "ctx",
1665    };
1666
1667    // Pre-compute action type and values
1668    let (action_type, action_value) = match action {
1669        CallbackAction::Skip => ("skip", String::new()),
1670        CallbackAction::Continue => ("continue", String::new()),
1671        CallbackAction::PreserveHtml => ("preserve_html", String::new()),
1672        CallbackAction::Custom { output } => {
1673            let escaped = ruby_string_literal(output);
1674            ("custom", escaped)
1675        }
1676        CallbackAction::CustomTemplate { template } => {
1677            let interpolated = ruby_template_to_interpolation(template);
1678            ("custom", format!("\"{interpolated}\""))
1679        }
1680    };
1681
1682    let rendered = crate::template_env::render(
1683        "ruby/visitor_method.jinja",
1684        minijinja::context! {
1685            method_name => method_name,
1686            params => params,
1687            action_type => action_type,
1688            action_value => action_value,
1689        },
1690    );
1691    for line in rendered.lines() {
1692        setup_lines.push(line.to_string());
1693    }
1694}
1695
1696/// Classify a fixture string value that maps to a `bytes` argument.
1697///
1698/// Returns true if the value looks like a file path (e.g. "pdf/fake_memo.pdf").
1699/// File paths have the pattern: alphanumeric/something.extension
1700fn is_file_path(s: &str) -> bool {
1701    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1702        return false;
1703    }
1704
1705    let first = s.chars().next().unwrap_or('\0');
1706    if first.is_ascii_alphanumeric() || first == '_' {
1707        if let Some(slash_pos) = s.find('/') {
1708            if slash_pos > 0 {
1709                let after_slash = &s[slash_pos + 1..];
1710                if after_slash.contains('.') && !after_slash.is_empty() {
1711                    return true;
1712                }
1713            }
1714        }
1715    }
1716
1717    false
1718}
1719
1720/// Check if a string looks like base64-encoded data.
1721/// If it's not a file path or inline text, assume it's base64.
1722fn is_base64(s: &str) -> bool {
1723    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1724        return false;
1725    }
1726
1727    if is_file_path(s) {
1728        return false;
1729    }
1730
1731    true
1732}