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