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