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({}, \"{}\", {})",
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!("Kreuzberg::{}.new(\"{}\", {})", elem_type, path, config_arg))
782                        }
783                        _ => None,
784                    }
785                } else {
786                    None
787                }
788            })
789            .collect();
790        format!("[{}]", item_strs.join(", "))
791    } else {
792        "[]".to_string()
793    }
794}
795
796fn build_args_and_setup(
797    input: &serde_json::Value,
798    args: &[crate::config::ArgMapping],
799    call_receiver: &str,
800    options_type: Option<&str>,
801    enum_fields: &HashMap<String, String>,
802    result_is_simple: bool,
803    fixture_id: &str,
804) -> (Vec<String>, String) {
805    if args.is_empty() {
806        // No args config: pass the whole input only when it's non-empty.
807        // Functions with no parameters have empty input and must be called
808        // with no arguments — not with `{}` or `nil`.
809        let is_empty_input = match input {
810            serde_json::Value::Null => true,
811            serde_json::Value::Object(m) => m.is_empty(),
812            _ => false,
813        };
814        if is_empty_input {
815            return (Vec::new(), String::new());
816        }
817        return (Vec::new(), json_to_ruby(input));
818    }
819
820    let mut setup_lines: Vec<String> = Vec::new();
821    let mut parts: Vec<String> = Vec::new();
822    // Track optional args that were skipped; if a later arg is emitted we must back-fill nil
823    // to preserve positional correctness (e.g. extract_file(path, nil, config)).
824    let mut skipped_optional_count: usize = 0;
825
826    for arg in args {
827        if arg.arg_type == "mock_url" {
828            // Flush any pending nil placeholders for skipped optionals before this positional arg.
829            for _ in 0..skipped_optional_count {
830                parts.push("nil".to_string());
831            }
832            skipped_optional_count = 0;
833            setup_lines.push(format!(
834                "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
835                arg.name,
836            ));
837            parts.push(arg.name.clone());
838            continue;
839        }
840
841        // Handle bytes arguments: load from file if needed
842        if arg.arg_type == "bytes" {
843            // Flush any pending nil placeholders for skipped optionals before this positional arg.
844            for _ in 0..skipped_optional_count {
845                parts.push("nil".to_string());
846            }
847            skipped_optional_count = 0;
848            let resolved = resolve_field(input, &arg.field);
849            if let Some(s) = resolved.as_str() {
850                if is_file_path(s) {
851                    // File path: load with File.read and convert to bytes array
852                    setup_lines.push(format!("{} = File.read(\"{}\").bytes", arg.name, s));
853                } else if is_base64(s) {
854                    // Base64: decode it
855                    setup_lines.push(format!("{} = Base64.decode64(\"{}\").bytes", arg.name, s));
856                } else {
857                    // Inline text: encode to binary and convert to bytes array
858                    let escaped = ruby_string_literal(s);
859                    setup_lines.push(format!("{} = {}.b.bytes", arg.name, escaped));
860                }
861                parts.push(arg.name.clone());
862            } else {
863                parts.push("nil".to_string());
864            }
865            continue;
866        }
867
868        // Handle file_path arguments: pass the path string as-is
869        if arg.arg_type == "file_path" {
870            // Flush any pending nil placeholders for skipped optionals before this positional arg.
871            for _ in 0..skipped_optional_count {
872                parts.push("nil".to_string());
873            }
874            skipped_optional_count = 0;
875            let resolved = resolve_field(input, &arg.field);
876            if let Some(s) = resolved.as_str() {
877                let escaped = ruby_string_literal(s);
878                parts.push(escaped);
879            } else if arg.optional {
880                skipped_optional_count += 1;
881                continue;
882            } else {
883                parts.push("''".to_string());
884            }
885            continue;
886        }
887
888        if arg.arg_type == "handle" {
889            // Flush any pending nil placeholders for skipped optionals before this positional arg.
890            for _ in 0..skipped_optional_count {
891                parts.push("nil".to_string());
892            }
893            skipped_optional_count = 0;
894            // Generate a create_engine (or equivalent) call and pass the variable.
895            let constructor_name = format!("create_{}", arg.name.to_snake_case());
896            let config_value = resolve_field(input, &arg.field);
897            if config_value.is_null()
898                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
899            {
900                setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
901            } else {
902                let literal = json_to_ruby(config_value);
903                let name = &arg.name;
904                setup_lines.push(format!("{name}_config = {literal}"));
905                setup_lines.push(format!(
906                    "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
907                    arg.name,
908                    name = name,
909                ));
910            }
911            parts.push(arg.name.clone());
912            continue;
913        }
914
915        let resolved = resolve_field(input, &arg.field);
916        let val = if resolved.is_null() { None } else { Some(resolved) };
917        match val {
918            None | Some(serde_json::Value::Null) if arg.optional => {
919                // Optional arg with no fixture value: defer; emit nil only if a later arg is present.
920                skipped_optional_count += 1;
921                continue;
922            }
923            None | Some(serde_json::Value::Null) => {
924                // Required arg with no fixture value: flush deferred nils, then pass a default.
925                for _ in 0..skipped_optional_count {
926                    parts.push("nil".to_string());
927                }
928                skipped_optional_count = 0;
929                let default_val = match arg.arg_type.as_str() {
930                    "string" => "''".to_string(),
931                    "int" | "integer" => "0".to_string(),
932                    "float" | "number" => "0.0".to_string(),
933                    "bool" | "boolean" => "false".to_string(),
934                    _ => "nil".to_string(),
935                };
936                parts.push(default_val);
937            }
938            Some(v) => {
939                // Flush deferred nil placeholders for skipped optional args that precede this one.
940                for _ in 0..skipped_optional_count {
941                    parts.push("nil".to_string());
942                }
943                skipped_optional_count = 0;
944                // For json_object args with options_type, construct a typed options object.
945                // When result_is_simple, the binding accepts a plain Hash (no wrapper class).
946                if arg.arg_type == "json_object" && !v.is_null() {
947                    // Check for batch item arrays (element_type set to BatchBytesItem/BatchFileItem)
948                    if let Some(elem_type) = &arg.element_type {
949                        if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
950                            parts.push(emit_ruby_batch_item_array(v, elem_type));
951                            continue;
952                        }
953                    }
954                    // Otherwise handle regular options_type objects
955                    if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
956                        let kwargs: Vec<String> = obj
957                            .iter()
958                            .map(|(k, vv)| {
959                                let snake_key = k.to_snake_case();
960                                let rb_val = if enum_fields.contains_key(k) {
961                                    if let Some(s) = vv.as_str() {
962                                        let snake_val = s.to_snake_case();
963                                        format!("'{snake_val}'")
964                                    } else {
965                                        json_to_ruby(vv)
966                                    }
967                                } else {
968                                    json_to_ruby(vv)
969                                };
970                                format!("{snake_key}: {rb_val}")
971                            })
972                            .collect();
973                        if result_is_simple {
974                            parts.push(format!("{{{}}}", kwargs.join(", ")));
975                        } else {
976                            parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
977                        }
978                        continue;
979                    }
980                }
981                parts.push(json_to_ruby(v));
982            }
983        }
984    }
985
986    (setup_lines, parts.join(", "))
987}
988
989fn render_assertion(
990    out: &mut String,
991    assertion: &Assertion,
992    result_var: &str,
993    field_resolver: &FieldResolver,
994    result_is_simple: bool,
995    e2e_config: &E2eConfig,
996) {
997    // For simple-result methods (e.g. `speech` returning bytes), every field-based
998    // assertion targets the result itself — there's no struct to access. Drop
999    // length-only assertions onto the result directly and skip anything else.
1000    if result_is_simple {
1001        if let Some(f) = &assertion.field {
1002            if !f.is_empty() {
1003                match assertion.assertion_type.as_str() {
1004                    "not_empty" => {
1005                        out.push_str(&format!("    expect({result_var}.to_s).not_to be_empty\n"));
1006                        return;
1007                    }
1008                    "is_empty" => {
1009                        out.push_str(&format!("    expect({result_var}.to_s).to be_empty\n"));
1010                        return;
1011                    }
1012                    "count_equals" => {
1013                        if let Some(val) = &assertion.value {
1014                            let rb_val = json_to_ruby(val);
1015                            out.push_str(&format!("    expect({result_var}.length).to eq({rb_val})\n"));
1016                        }
1017                        return;
1018                    }
1019                    "count_min" => {
1020                        if let Some(val) = &assertion.value {
1021                            let rb_val = json_to_ruby(val);
1022                            out.push_str(&format!("    expect({result_var}.length).to be >= {rb_val}\n"));
1023                        }
1024                        return;
1025                    }
1026                    _ => {
1027                        out.push_str(&format!(
1028                            "    # skipped: field '{f}' not applicable for simple result type\n"
1029                        ));
1030                        return;
1031                    }
1032                }
1033            }
1034        }
1035    }
1036    // Handle synthetic / derived fields before the is_valid_for_result check
1037    // so they are never treated as struct attribute accesses on the result.
1038    if let Some(f) = &assertion.field {
1039        match f.as_str() {
1040            "chunks_have_content" => {
1041                let pred = format!("({result_var}.chunks || []).all? {{ |c| c.content && !c.content.empty? }}");
1042                match assertion.assertion_type.as_str() {
1043                    "is_true" => {
1044                        out.push_str(&format!("    expect({pred}).to be(true)\n"));
1045                    }
1046                    "is_false" => {
1047                        out.push_str(&format!("    expect({pred}).to be(false)\n"));
1048                    }
1049                    _ => {
1050                        out.push_str(&format!(
1051                            "    # skipped: unsupported assertion type on synthetic field '{f}'\n"
1052                        ));
1053                    }
1054                }
1055                return;
1056            }
1057            "chunks_have_embeddings" => {
1058                let pred =
1059                    format!("({result_var}.chunks || []).all? {{ |c| !c.embedding.nil? && !c.embedding.empty? }}");
1060                match assertion.assertion_type.as_str() {
1061                    "is_true" => {
1062                        out.push_str(&format!("    expect({pred}).to be(true)\n"));
1063                    }
1064                    "is_false" => {
1065                        out.push_str(&format!("    expect({pred}).to be(false)\n"));
1066                    }
1067                    _ => {
1068                        out.push_str(&format!(
1069                            "    # skipped: unsupported assertion type on synthetic field '{f}'\n"
1070                        ));
1071                    }
1072                }
1073                return;
1074            }
1075            // ---- EmbedResponse virtual fields ----
1076            // embed_texts returns Array<Array<Float>> in Ruby — no wrapper struct.
1077            // result_var is the embedding matrix; use it directly.
1078            "embeddings" => {
1079                match assertion.assertion_type.as_str() {
1080                    "count_equals" => {
1081                        if let Some(val) = &assertion.value {
1082                            let rb_val = json_to_ruby(val);
1083                            out.push_str(&format!("    expect({result_var}.length).to eq({rb_val})\n"));
1084                        }
1085                    }
1086                    "count_min" => {
1087                        if let Some(val) = &assertion.value {
1088                            let rb_val = json_to_ruby(val);
1089                            out.push_str(&format!("    expect({result_var}.length).to be >= {rb_val}\n"));
1090                        }
1091                    }
1092                    "not_empty" => {
1093                        out.push_str(&format!("    expect({result_var}).not_to be_empty\n"));
1094                    }
1095                    "is_empty" => {
1096                        out.push_str(&format!("    expect({result_var}).to be_empty\n"));
1097                    }
1098                    _ => {
1099                        out.push_str("    # skipped: unsupported assertion type on synthetic field 'embeddings'\n");
1100                    }
1101                }
1102                return;
1103            }
1104            "embedding_dimensions" => {
1105                let expr = format!("({result_var}.empty? ? 0 : {result_var}[0].length)");
1106                match assertion.assertion_type.as_str() {
1107                    "equals" => {
1108                        if let Some(val) = &assertion.value {
1109                            let rb_val = json_to_ruby(val);
1110                            out.push_str(&format!("    expect({expr}).to eq({rb_val})\n"));
1111                        }
1112                    }
1113                    "greater_than" => {
1114                        if let Some(val) = &assertion.value {
1115                            let rb_val = json_to_ruby(val);
1116                            out.push_str(&format!("    expect({expr}).to be > {rb_val}\n"));
1117                        }
1118                    }
1119                    _ => {
1120                        out.push_str(
1121                            "    # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n",
1122                        );
1123                    }
1124                }
1125                return;
1126            }
1127            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1128                let pred = match f.as_str() {
1129                    "embeddings_valid" => {
1130                        format!("{result_var}.all? {{ |e| !e.empty? }}")
1131                    }
1132                    "embeddings_finite" => {
1133                        format!("{result_var}.all? {{ |e| e.all? {{ |v| v.finite? }} }}")
1134                    }
1135                    "embeddings_non_zero" => {
1136                        format!("{result_var}.all? {{ |e| e.any? {{ |v| v != 0.0 }} }}")
1137                    }
1138                    "embeddings_normalized" => {
1139                        format!("{result_var}.all? {{ |e| n = e.sum {{ |v| v * v }}; (n - 1.0).abs < 1e-3 }}")
1140                    }
1141                    _ => unreachable!(),
1142                };
1143                match assertion.assertion_type.as_str() {
1144                    "is_true" => {
1145                        out.push_str(&format!("    expect({pred}).to be(true)\n"));
1146                    }
1147                    "is_false" => {
1148                        out.push_str(&format!("    expect({pred}).to be(false)\n"));
1149                    }
1150                    _ => {
1151                        out.push_str(&format!(
1152                            "    # skipped: unsupported assertion type on synthetic field '{f}'\n"
1153                        ));
1154                    }
1155                }
1156                return;
1157            }
1158            // ---- keywords / keywords_count ----
1159            // Ruby ExtractionResult does not expose extracted_keywords; skip.
1160            "keywords" | "keywords_count" => {
1161                out.push_str(&format!(
1162                    "    # skipped: field '{f}' not available on Ruby ExtractionResult\n"
1163                ));
1164                return;
1165            }
1166            _ => {}
1167        }
1168    }
1169
1170    // Skip assertions on fields that don't exist on the result type.
1171    if let Some(f) = &assertion.field {
1172        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1173            out.push_str(&format!("    # skipped: field '{f}' not available on result type\n"));
1174            return;
1175        }
1176    }
1177
1178    // When result_is_simple, skip assertions that reference non-content fields.
1179    if result_is_simple {
1180        if let Some(f) = &assertion.field {
1181            let f_lower = f.to_lowercase();
1182            if !f.is_empty()
1183                && f_lower != "content"
1184                && (f_lower.starts_with("metadata")
1185                    || f_lower.starts_with("document")
1186                    || f_lower.starts_with("structure"))
1187            {
1188                return;
1189            }
1190        }
1191    }
1192
1193    // result_is_simple: treat the result itself as the content string, but only
1194    // when there is no explicit field (or the field is "content"). Count/length
1195    // assertions on named fields (e.g. "warnings") must still walk the field path.
1196    let field_expr = match &assertion.field {
1197        Some(f) if !f.is_empty() && (!result_is_simple || !f.eq_ignore_ascii_case("content")) => {
1198            field_resolver.accessor(f, "ruby", result_var)
1199        }
1200        _ => result_var.to_string(),
1201    };
1202
1203    // For string equality, strip trailing whitespace to handle trailing newlines
1204    // from the converter. Ruby enum fields (Magnus binds Rust enums as Symbols),
1205    // are coerced to String via .to_s so `eq("stop")` matches `:stop`.
1206    let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1207        let resolved = field_resolver.resolve(f);
1208        e2e_config.fields_enum.contains(f) || e2e_config.fields_enum.contains(resolved)
1209    });
1210    let stripped_field_expr = if result_is_simple {
1211        format!("{field_expr}.to_s.strip")
1212    } else if field_is_enum {
1213        format!("{field_expr}.to_s")
1214    } else {
1215        field_expr.clone()
1216    };
1217
1218    // Detect whether the assertion field resolves to an array type so that
1219    // contains assertions can iterate items instead of calling .to_s on the array.
1220    let field_is_array = assertion
1221        .field
1222        .as_deref()
1223        .filter(|f| !f.is_empty())
1224        .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1225
1226    match assertion.assertion_type.as_str() {
1227        "equals" => {
1228            if let Some(expected) = &assertion.value {
1229                let is_boolean_val = expected.as_bool().is_some();
1230                let bool_val = expected
1231                    .as_bool()
1232                    .map(|b| if b { "true" } else { "false" })
1233                    .unwrap_or("");
1234                let rb_val = json_to_ruby(expected);
1235
1236                let rendered = crate::template_env::render(
1237                    "ruby/assertion.jinja",
1238                    minijinja::context! {
1239                        assertion_type => "equals",
1240                        stripped_field_expr => stripped_field_expr.clone(),
1241                        is_boolean_val => is_boolean_val,
1242                        bool_val => bool_val,
1243                        expected_val => rb_val,
1244                    },
1245                );
1246                out.push_str(&rendered);
1247            }
1248        }
1249        "contains" => {
1250            if let Some(expected) = &assertion.value {
1251                let rb_val = json_to_ruby(expected);
1252                let rendered = crate::template_env::render(
1253                    "ruby/assertion.jinja",
1254                    minijinja::context! {
1255                        assertion_type => "contains",
1256                        field_expr => field_expr.clone(),
1257                        field_is_array => field_is_array && expected.is_string(),
1258                        expected_val => rb_val,
1259                    },
1260                );
1261                out.push_str(&rendered);
1262            }
1263        }
1264        "contains_all" => {
1265            if let Some(values) = &assertion.values {
1266                let values_list: Vec<String> = values.iter().map(json_to_ruby).collect();
1267                let rendered = crate::template_env::render(
1268                    "ruby/assertion.jinja",
1269                    minijinja::context! {
1270                        assertion_type => "contains_all",
1271                        field_expr => field_expr.clone(),
1272                        field_is_array => field_is_array,
1273                        values_list => values_list,
1274                    },
1275                );
1276                out.push_str(&rendered);
1277            }
1278        }
1279        "not_contains" => {
1280            if let Some(expected) = &assertion.value {
1281                let rb_val = json_to_ruby(expected);
1282                let rendered = crate::template_env::render(
1283                    "ruby/assertion.jinja",
1284                    minijinja::context! {
1285                        assertion_type => "not_contains",
1286                        field_expr => field_expr.clone(),
1287                        field_is_array => field_is_array && expected.is_string(),
1288                        expected_val => rb_val,
1289                    },
1290                );
1291                out.push_str(&rendered);
1292            }
1293        }
1294        "not_empty" => {
1295            let rendered = crate::template_env::render(
1296                "ruby/assertion.jinja",
1297                minijinja::context! {
1298                    assertion_type => "not_empty",
1299                    field_expr => field_expr.clone(),
1300                },
1301            );
1302            out.push_str(&rendered);
1303        }
1304        "is_empty" => {
1305            let rendered = crate::template_env::render(
1306                "ruby/assertion.jinja",
1307                minijinja::context! {
1308                    assertion_type => "is_empty",
1309                    field_expr => field_expr.clone(),
1310                },
1311            );
1312            out.push_str(&rendered);
1313        }
1314        "contains_any" => {
1315            if let Some(values) = &assertion.values {
1316                let items: Vec<String> = values.iter().map(json_to_ruby).collect();
1317                let rendered = crate::template_env::render(
1318                    "ruby/assertion.jinja",
1319                    minijinja::context! {
1320                        assertion_type => "contains_any",
1321                        field_expr => field_expr.clone(),
1322                        values_list => items,
1323                    },
1324                );
1325                out.push_str(&rendered);
1326            }
1327        }
1328        "greater_than" => {
1329            if let Some(val) = &assertion.value {
1330                let rb_val = json_to_ruby(val);
1331                let rendered = crate::template_env::render(
1332                    "ruby/assertion.jinja",
1333                    minijinja::context! {
1334                        assertion_type => "greater_than",
1335                        field_expr => field_expr.clone(),
1336                        expected_val => rb_val,
1337                    },
1338                );
1339                out.push_str(&rendered);
1340            }
1341        }
1342        "less_than" => {
1343            if let Some(val) = &assertion.value {
1344                let rb_val = json_to_ruby(val);
1345                let rendered = crate::template_env::render(
1346                    "ruby/assertion.jinja",
1347                    minijinja::context! {
1348                        assertion_type => "less_than",
1349                        field_expr => field_expr.clone(),
1350                        expected_val => rb_val,
1351                    },
1352                );
1353                out.push_str(&rendered);
1354            }
1355        }
1356        "greater_than_or_equal" => {
1357            if let Some(val) = &assertion.value {
1358                let rb_val = json_to_ruby(val);
1359                let rendered = crate::template_env::render(
1360                    "ruby/assertion.jinja",
1361                    minijinja::context! {
1362                        assertion_type => "greater_than_or_equal",
1363                        field_expr => field_expr.clone(),
1364                        expected_val => rb_val,
1365                    },
1366                );
1367                out.push_str(&rendered);
1368            }
1369        }
1370        "less_than_or_equal" => {
1371            if let Some(val) = &assertion.value {
1372                let rb_val = json_to_ruby(val);
1373                let rendered = crate::template_env::render(
1374                    "ruby/assertion.jinja",
1375                    minijinja::context! {
1376                        assertion_type => "less_than_or_equal",
1377                        field_expr => field_expr.clone(),
1378                        expected_val => rb_val,
1379                    },
1380                );
1381                out.push_str(&rendered);
1382            }
1383        }
1384        "starts_with" => {
1385            if let Some(expected) = &assertion.value {
1386                let rb_val = json_to_ruby(expected);
1387                let rendered = crate::template_env::render(
1388                    "ruby/assertion.jinja",
1389                    minijinja::context! {
1390                        assertion_type => "starts_with",
1391                        field_expr => field_expr.clone(),
1392                        expected_val => rb_val,
1393                    },
1394                );
1395                out.push_str(&rendered);
1396            }
1397        }
1398        "ends_with" => {
1399            if let Some(expected) = &assertion.value {
1400                let rb_val = json_to_ruby(expected);
1401                let rendered = crate::template_env::render(
1402                    "ruby/assertion.jinja",
1403                    minijinja::context! {
1404                        assertion_type => "ends_with",
1405                        field_expr => field_expr.clone(),
1406                        expected_val => rb_val,
1407                    },
1408                );
1409                out.push_str(&rendered);
1410            }
1411        }
1412        "min_length" | "max_length" | "count_min" | "count_equals" => {
1413            if let Some(val) = &assertion.value {
1414                if let Some(n) = val.as_u64() {
1415                    let rendered = crate::template_env::render(
1416                        "ruby/assertion.jinja",
1417                        minijinja::context! {
1418                            assertion_type => assertion.assertion_type.as_str(),
1419                            field_expr => field_expr.clone(),
1420                            check_n => n,
1421                        },
1422                    );
1423                    out.push_str(&rendered);
1424                }
1425            }
1426        }
1427        "is_true" => {
1428            let rendered = crate::template_env::render(
1429                "ruby/assertion.jinja",
1430                minijinja::context! {
1431                    assertion_type => "is_true",
1432                    field_expr => field_expr.clone(),
1433                },
1434            );
1435            out.push_str(&rendered);
1436        }
1437        "is_false" => {
1438            let rendered = crate::template_env::render(
1439                "ruby/assertion.jinja",
1440                minijinja::context! {
1441                    assertion_type => "is_false",
1442                    field_expr => field_expr.clone(),
1443                },
1444            );
1445            out.push_str(&rendered);
1446        }
1447        "method_result" => {
1448            if let Some(method_name) = &assertion.method {
1449                // Derive call_receiver for module-level helper calls.
1450                let lang = "ruby";
1451                let call = &e2e_config.call;
1452                let overrides = call.overrides.get(lang);
1453                let module_path = overrides
1454                    .and_then(|o| o.module.as_ref())
1455                    .cloned()
1456                    .unwrap_or_else(|| call.module.clone());
1457                let call_receiver = ruby_module_name(&module_path);
1458
1459                let call_expr =
1460                    build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
1461                let check = assertion.check.as_deref().unwrap_or("is_true");
1462
1463                let (check_val_str, is_boolean_check, bool_check_val, check_n_val) = match check {
1464                    "equals" => {
1465                        if let Some(val) = &assertion.value {
1466                            let is_bool = val.as_bool().is_some();
1467                            let bool_str = val.as_bool().map(|b| if b { "true" } else { "false" }).unwrap_or("");
1468                            let rb_val = json_to_ruby(val);
1469                            (rb_val, is_bool, bool_str.to_string(), 0)
1470                        } else {
1471                            (String::new(), false, String::new(), 0)
1472                        }
1473                    }
1474                    "greater_than_or_equal" => {
1475                        if let Some(val) = &assertion.value {
1476                            (json_to_ruby(val), false, String::new(), 0)
1477                        } else {
1478                            (String::new(), false, String::new(), 0)
1479                        }
1480                    }
1481                    "count_min" => {
1482                        if let Some(val) = &assertion.value {
1483                            let n = val.as_u64().unwrap_or(0);
1484                            (String::new(), false, String::new(), n)
1485                        } else {
1486                            (String::new(), false, String::new(), 0)
1487                        }
1488                    }
1489                    "contains" => {
1490                        if let Some(val) = &assertion.value {
1491                            (json_to_ruby(val), false, String::new(), 0)
1492                        } else {
1493                            (String::new(), false, String::new(), 0)
1494                        }
1495                    }
1496                    _ => (String::new(), false, String::new(), 0),
1497                };
1498
1499                let rendered = crate::template_env::render(
1500                    "ruby/assertion.jinja",
1501                    minijinja::context! {
1502                        assertion_type => "method_result",
1503                        call_expr => call_expr,
1504                        check => check,
1505                        check_val => check_val_str,
1506                        is_boolean_check => is_boolean_check,
1507                        bool_check_val => bool_check_val,
1508                        check_n => check_n_val,
1509                    },
1510                );
1511                out.push_str(&rendered);
1512            } else {
1513                panic!("Ruby e2e generator: method_result assertion missing 'method' field");
1514            }
1515        }
1516        "matches_regex" => {
1517            if let Some(expected) = &assertion.value {
1518                let rb_val = json_to_ruby(expected);
1519                let rendered = crate::template_env::render(
1520                    "ruby/assertion.jinja",
1521                    minijinja::context! {
1522                        assertion_type => "matches_regex",
1523                        field_expr => field_expr.clone(),
1524                        expected_val => rb_val,
1525                    },
1526                );
1527                out.push_str(&rendered);
1528            }
1529        }
1530        "not_error" => {
1531            // Already handled by the call succeeding without exception.
1532        }
1533        "error" => {
1534            // Handled at the example level.
1535        }
1536        other => {
1537            panic!("Ruby e2e generator: unsupported assertion type: {other}");
1538        }
1539    }
1540}
1541
1542/// Build a Ruby call expression for a `method_result` assertion on a tree-sitter Tree.
1543/// Maps method names to the appropriate Ruby method or module-function calls.
1544fn build_ruby_method_call(
1545    call_receiver: &str,
1546    result_var: &str,
1547    method_name: &str,
1548    args: Option<&serde_json::Value>,
1549) -> String {
1550    match method_name {
1551        "root_child_count" => format!("{result_var}.root_node.child_count"),
1552        "root_node_type" => format!("{result_var}.root_node.type"),
1553        "named_children_count" => format!("{result_var}.root_node.named_child_count"),
1554        "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
1555        "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
1556        "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
1557        "contains_node_type" => {
1558            let node_type = args
1559                .and_then(|a| a.get("node_type"))
1560                .and_then(|v| v.as_str())
1561                .unwrap_or("");
1562            format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
1563        }
1564        "find_nodes_by_type" => {
1565            let node_type = args
1566                .and_then(|a| a.get("node_type"))
1567                .and_then(|v| v.as_str())
1568                .unwrap_or("");
1569            format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
1570        }
1571        "run_query" => {
1572            let query_source = args
1573                .and_then(|a| a.get("query_source"))
1574                .and_then(|v| v.as_str())
1575                .unwrap_or("");
1576            let language = args
1577                .and_then(|a| a.get("language"))
1578                .and_then(|v| v.as_str())
1579                .unwrap_or("");
1580            format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1581        }
1582        _ => format!("{result_var}.{method_name}"),
1583    }
1584}
1585
1586/// Convert a module path (e.g., "html_to_markdown") to Ruby PascalCase module name
1587/// (e.g., "HtmlToMarkdown").
1588fn ruby_module_name(module_path: &str) -> String {
1589    use heck::ToUpperCamelCase;
1590    module_path.to_upper_camel_case()
1591}
1592
1593/// Convert a `serde_json::Value` to a Ruby literal string, preferring single quotes.
1594fn json_to_ruby(value: &serde_json::Value) -> String {
1595    match value {
1596        serde_json::Value::String(s) => ruby_string_literal(s),
1597        serde_json::Value::Bool(true) => "true".to_string(),
1598        serde_json::Value::Bool(false) => "false".to_string(),
1599        serde_json::Value::Number(n) => n.to_string(),
1600        serde_json::Value::Null => "nil".to_string(),
1601        serde_json::Value::Array(arr) => {
1602            let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
1603            format!("[{}]", items.join(", "))
1604        }
1605        serde_json::Value::Object(map) => {
1606            let items: Vec<String> = map
1607                .iter()
1608                .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
1609                .collect();
1610            format!("{{ {} }}", items.join(", "))
1611        }
1612    }
1613}
1614
1615// ---------------------------------------------------------------------------
1616// Visitor generation
1617// ---------------------------------------------------------------------------
1618
1619/// Build a Ruby visitor object and add setup lines. Returns the visitor expression.
1620fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1621    setup_lines.push("visitor = Class.new do".to_string());
1622    for (method_name, action) in &visitor_spec.callbacks {
1623        emit_ruby_visitor_method(setup_lines, method_name, action);
1624    }
1625    setup_lines.push("end.new".to_string());
1626    "visitor".to_string()
1627}
1628
1629/// Emit a Ruby visitor method for a callback action.
1630fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1631    let params = match method_name {
1632        "visit_link" => "ctx, href, text, title",
1633        "visit_image" => "ctx, src, alt, title",
1634        "visit_heading" => "ctx, level, text, id",
1635        "visit_code_block" => "ctx, lang, code",
1636        "visit_code_inline"
1637        | "visit_strong"
1638        | "visit_emphasis"
1639        | "visit_strikethrough"
1640        | "visit_underline"
1641        | "visit_subscript"
1642        | "visit_superscript"
1643        | "visit_mark"
1644        | "visit_button"
1645        | "visit_summary"
1646        | "visit_figcaption"
1647        | "visit_definition_term"
1648        | "visit_definition_description" => "ctx, text",
1649        "visit_text" => "ctx, text",
1650        "visit_list_item" => "ctx, ordered, marker, text",
1651        "visit_blockquote" => "ctx, content, depth",
1652        "visit_table_row" => "ctx, cells, is_header",
1653        "visit_custom_element" => "ctx, tag_name, html",
1654        "visit_form" => "ctx, action_url, method",
1655        "visit_input" => "ctx, input_type, name, value",
1656        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1657        "visit_details" => "ctx, is_open",
1658        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1659        "visit_list_start" => "ctx, ordered",
1660        "visit_list_end" => "ctx, ordered, output",
1661        _ => "ctx",
1662    };
1663
1664    // Pre-compute action type and values
1665    let (action_type, action_value) = match action {
1666        CallbackAction::Skip => ("skip", String::new()),
1667        CallbackAction::Continue => ("continue", String::new()),
1668        CallbackAction::PreserveHtml => ("preserve_html", String::new()),
1669        CallbackAction::Custom { output } => {
1670            let escaped = ruby_string_literal(output);
1671            ("custom", escaped)
1672        }
1673        CallbackAction::CustomTemplate { template } => {
1674            let interpolated = ruby_template_to_interpolation(template);
1675            ("custom", format!("\"{interpolated}\""))
1676        }
1677    };
1678
1679    let rendered = crate::template_env::render(
1680        "ruby/visitor_method.jinja",
1681        minijinja::context! {
1682            method_name => method_name,
1683            params => params,
1684            action_type => action_type,
1685            action_value => action_value,
1686        },
1687    );
1688    for line in rendered.lines() {
1689        setup_lines.push(line.to_string());
1690    }
1691}
1692
1693/// Classify a fixture string value that maps to a `bytes` argument.
1694///
1695/// Returns true if the value looks like a file path (e.g. "pdf/fake_memo.pdf").
1696/// File paths have the pattern: alphanumeric/something.extension
1697fn is_file_path(s: &str) -> bool {
1698    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1699        return false;
1700    }
1701
1702    let first = s.chars().next().unwrap_or('\0');
1703    if first.is_ascii_alphanumeric() || first == '_' {
1704        if let Some(slash_pos) = s.find('/') {
1705            if slash_pos > 0 {
1706                let after_slash = &s[slash_pos + 1..];
1707                if after_slash.contains('.') && !after_slash.is_empty() {
1708                    return true;
1709                }
1710            }
1711        }
1712    }
1713
1714    false
1715}
1716
1717/// Check if a string looks like base64-encoded data.
1718/// If it's not a file path or inline text, assume it's base64.
1719fn is_base64(s: &str) -> bool {
1720    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1721        return false;
1722    }
1723
1724    if is_file_path(s) {
1725        return false;
1726    }
1727
1728    true
1729}