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