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::config::E2eConfig;
7use crate::escape::{ruby_string_literal, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{
10    Assertion, CallbackAction, Fixture, FixtureGroup, HttpExpectedResponse, HttpFixture, HttpRequest,
11};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::AlefConfig;
14use alef_core::hash::{self, CommentStyle};
15use alef_core::template_versions as tv;
16use anyhow::Result;
17use heck::ToSnakeCase;
18use std::collections::HashMap;
19use std::fmt::Write as FmtWrite;
20use std::path::PathBuf;
21
22use super::E2eCodegen;
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        alef_config: &AlefConfig,
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 = 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(|| alef_config.crate_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        // Generate spec/spec_helper.rb when HTTP fixtures are present.
88        if has_http_fixtures {
89            files.push(GeneratedFile {
90                path: output_base.join("spec").join("spec_helper.rb"),
91                content: render_spec_helper(),
92                generated_header: true,
93            });
94        }
95
96        // Generate spec files per category.
97        let spec_base = output_base.join("spec");
98
99        for group in groups {
100            let active: Vec<&Fixture> = group
101                .fixtures
102                .iter()
103                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
104                .collect();
105
106            if active.is_empty() {
107                continue;
108            }
109
110            let field_resolver_pre = FieldResolver::new(
111                &e2e_config.fields,
112                &e2e_config.fields_optional,
113                &e2e_config.result_fields,
114                &e2e_config.fields_array,
115            );
116            // Skip the entire file if no fixture in this category produces output.
117            let has_any_output = active.iter().any(|f| {
118                // HTTP tests always produce output.
119                if f.is_http_test() {
120                    return true;
121                }
122                let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
123                expects_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
124            });
125            if !has_any_output {
126                continue;
127            }
128
129            let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
130            let field_resolver = FieldResolver::new(
131                &e2e_config.fields,
132                &e2e_config.fields_optional,
133                &e2e_config.result_fields,
134                &e2e_config.fields_array,
135            );
136            let content = render_spec_file(
137                &group.category,
138                &active,
139                &module_path,
140                class_name.as_deref(),
141                &gem_name,
142                &field_resolver,
143                options_type.as_deref(),
144                enum_fields,
145                result_is_simple,
146                e2e_config,
147            );
148            files.push(GeneratedFile {
149                path: spec_base.join(filename),
150                content,
151                generated_header: true,
152            });
153        }
154
155        Ok(files)
156    }
157
158    fn language_name(&self) -> &'static str {
159        "ruby"
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Rendering
165// ---------------------------------------------------------------------------
166
167fn render_gemfile(
168    gem_name: &str,
169    gem_path: &str,
170    gem_version: &str,
171    dep_mode: crate::config::DependencyMode,
172) -> String {
173    let gem_line = match dep_mode {
174        crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
175        crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
176    };
177    format!(
178        "# frozen_string_literal: true\n\
179         \n\
180         source 'https://rubygems.org'\n\
181         \n\
182         {gem_line}\n\
183         gem 'rspec', '{rspec}'\n\
184         gem 'rubocop', '{rubocop}'\n\
185         gem 'rubocop-rspec', '{rubocop_rspec}'\n\
186         gem 'faraday', '{faraday}'\n",
187        rspec = tv::gem::RSPEC_E2E,
188        rubocop = tv::gem::RUBOCOP_E2E,
189        rubocop_rspec = tv::gem::RUBOCOP_RSPEC_E2E,
190        faraday = tv::gem::FARADAY,
191    )
192}
193
194fn render_spec_helper() -> String {
195    let header = hash::header(CommentStyle::Hash);
196    header
197        + r#"# frozen_string_literal: true
198
199require 'open3'
200
201# Spawn the mock-server binary and set MOCK_SERVER_URL for all tests.
202RSpec.configure do |config|
203  config.before(:suite) do
204    bin = File.expand_path('../../rust/target/release/mock-server', __dir__)
205    fixtures_dir = File.expand_path('../../../fixtures', __dir__)
206    unless File.exist?(bin)
207      warn "mock-server binary not found at #{bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
208    end
209    stdin, stdout, _stderr, _wait = Open3.popen3(bin, fixtures_dir)
210    url = stdout.readline.strip.split('=', 2).last
211    ENV['MOCK_SERVER_URL'] = url
212    # Drain stdout in background.
213    Thread.new { stdout.read }
214    # Store stdin so we can close it on teardown.
215    @_mock_server_stdin = stdin
216  end
217
218  config.after(:suite) do
219    @_mock_server_stdin&.close
220  end
221end
222"#
223}
224
225fn render_rubocop_yaml() -> String {
226    r#"# Generated by alef e2e — do not edit.
227AllCops:
228  NewCops: enable
229  TargetRubyVersion: 3.2
230  SuggestExtensions: false
231
232plugins:
233  - rubocop-rspec
234
235# --- Justified suppressions for generated test code ---
236
237# Generated tests are verbose by nature (setup + multiple assertions).
238Metrics/BlockLength:
239  Enabled: false
240Metrics/MethodLength:
241  Enabled: false
242Layout/LineLength:
243  Enabled: false
244
245# Generated tests use multiple assertions per example for thorough verification.
246RSpec/MultipleExpectations:
247  Enabled: false
248RSpec/ExampleLength:
249  Enabled: false
250
251# Generated tests describe categories as strings, not classes.
252RSpec/DescribeClass:
253  Enabled: false
254
255# Fixture-driven tests may produce identical assertion bodies for different inputs.
256RSpec/RepeatedExample:
257  Enabled: false
258
259# Error-handling tests use bare raise_error (exception type not known at generation time).
260RSpec/UnspecifiedException:
261  Enabled: false
262"#
263    .to_string()
264}
265
266#[allow(clippy::too_many_arguments)]
267fn render_spec_file(
268    category: &str,
269    fixtures: &[&Fixture],
270    module_path: &str,
271    class_name: Option<&str>,
272    gem_name: &str,
273    field_resolver: &FieldResolver,
274    options_type: Option<&str>,
275    enum_fields: &HashMap<String, String>,
276    result_is_simple: bool,
277    e2e_config: &E2eConfig,
278) -> String {
279    let mut out = String::new();
280    out.push_str(&hash::header(CommentStyle::Hash));
281    let _ = writeln!(out, "# frozen_string_literal: true");
282    let _ = writeln!(out);
283
284    // Require the gem (single quotes).
285    let require_name = if module_path.is_empty() { gem_name } else { module_path };
286    let _ = writeln!(out, "require '{}'", require_name.replace('-', "_"));
287    let _ = writeln!(out, "require 'json'");
288
289    let has_http = fixtures.iter().any(|f| f.is_http_test());
290    if has_http {
291        // spec_helper sets up the mock server and MOCK_SERVER_URL env var.
292        let _ = writeln!(out, "require_relative 'spec_helper'");
293    }
294    let _ = writeln!(out);
295
296    // Build the Ruby module/class qualifier for calls.
297    let call_receiver = class_name
298        .map(|s| s.to_string())
299        .unwrap_or_else(|| ruby_module_name(module_path));
300
301    let _ = writeln!(out, "RSpec.describe '{}' do", category);
302
303    // Emit a shared client helper when there are HTTP tests.
304    if has_http {
305        let _ = writeln!(
306            out,
307            "  let(:mock_server_url) {{ ENV.fetch('MOCK_SERVER_URL', 'http://localhost:8080') }}"
308        );
309        let _ = writeln!(out);
310    }
311
312    let mut first = true;
313    for fixture in fixtures {
314        if !first {
315            let _ = writeln!(out);
316        }
317        first = false;
318
319        if let Some(http) = &fixture.http {
320            render_http_example(&mut out, fixture, http);
321        } else {
322            // Non-HTTP fixtures (WebSocket, SSE, gRPC, etc.) that have no usable assertions
323            // cannot be tested via Net::HTTP. Emit a pending example instead.
324            let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
325            let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
326            if !expects_error && !has_usable {
327                let test_name = sanitize_ident(&fixture.id);
328                let description = fixture.description.replace('\'', "\\'");
329                let _ = writeln!(out, "  it '{test_name}: {description}' do");
330                let _ = writeln!(out, "    skip 'Non-HTTP fixture cannot be tested via Net::HTTP'");
331                let _ = writeln!(out, "  end");
332            } else {
333                // Resolve per-fixture call config (supports named calls via fixture.call field).
334                let fixture_call = e2e_config.resolve_call(fixture.call.as_deref());
335                let fixture_call_overrides = fixture_call.overrides.get("ruby");
336                let fixture_function_name = fixture_call_overrides
337                    .and_then(|o| o.function.as_ref())
338                    .cloned()
339                    .unwrap_or_else(|| fixture_call.function.clone());
340                let fixture_result_var = &fixture_call.result_var;
341                let fixture_args = &fixture_call.args;
342                render_example(
343                    &mut out,
344                    fixture,
345                    &fixture_function_name,
346                    &call_receiver,
347                    fixture_result_var,
348                    fixture_args,
349                    field_resolver,
350                    options_type,
351                    enum_fields,
352                    result_is_simple,
353                    e2e_config,
354                );
355            }
356        }
357    }
358
359    let _ = writeln!(out, "end");
360    out
361}
362
363/// Check if a fixture has at least one assertion that will produce an executable
364/// expect() call (not just a skip comment).
365fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
366    fixture.assertions.iter().any(|a| {
367        // not_error is implicit (call succeeding), error is handled separately.
368        if a.assertion_type == "not_error" || a.assertion_type == "error" {
369            return false;
370        }
371        // Check field validity.
372        if let Some(f) = &a.field {
373            if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
374                return false;
375            }
376            // When result_is_simple, skip non-content fields.
377            if result_is_simple {
378                let f_lower = f.to_lowercase();
379                if !f.is_empty()
380                    && f_lower != "content"
381                    && (f_lower.starts_with("metadata")
382                        || f_lower.starts_with("document")
383                        || f_lower.starts_with("structure"))
384                {
385                    return false;
386                }
387            }
388        }
389        true
390    })
391}
392
393// ---------------------------------------------------------------------------
394// HTTP test rendering
395// ---------------------------------------------------------------------------
396
397/// Render an RSpec `describe` + `it` block for an HTTP server test fixture.
398fn render_http_example(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
399    let description = fixture.description.replace('\'', "\\'");
400    let method = http.request.method.to_uppercase();
401    let path = &http.request.path;
402    let fixture_id = &fixture.id;
403    let status = http.expected_response.status_code;
404
405    let _ = writeln!(out, "  describe '{method} {path}' do");
406
407    // HTTP 101 (WebSocket upgrade) cannot be tested via Net::HTTP — generate a skip.
408    if status == 101 {
409        let _ = writeln!(out, "    it '{}' do", description);
410        let _ = writeln!(
411            out,
412            "      skip 'HTTP 101 WebSocket upgrade cannot be tested via Net::HTTP'"
413        );
414        let _ = writeln!(out, "    end");
415        let _ = writeln!(out, "  end");
416        return;
417    }
418
419    let _ = writeln!(out, "    it '{}' do", description);
420
421    // Build request call targeting the mock server.
422    render_ruby_http_request_mock(out, &http.request, fixture_id);
423
424    // Assert status (Net::HTTP: response.code is a string, convert to int).
425    let _ = writeln!(out, "      expect(response.code.to_i).to eq({status})");
426
427    // Assert response body.
428    render_ruby_body_assertions(out, &http.expected_response);
429
430    // Assert response headers.
431    render_ruby_header_assertions(out, &http.expected_response);
432
433    let _ = writeln!(out, "    end");
434    let _ = writeln!(out, "  end");
435}
436
437/// Convert an uppercase HTTP method string to Ruby's Net::HTTP class name.
438/// Ruby uses title-cased names: Get, Post, Put, Delete, Patch, Head, Options, Trace.
439fn http_method_class(method: &str) -> String {
440    let mut chars = method.chars();
441    match chars.next() {
442        None => String::new(),
443        Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
444    }
445}
446
447/// Emit a Net::HTTP request to the mock server's `/fixtures/<id>` endpoint.
448fn render_ruby_http_request_mock(out: &mut String, req: &HttpRequest, fixture_id: &str) {
449    let method = req.method.to_uppercase();
450    let method_class = http_method_class(&method);
451    let _ = writeln!(out, "      require 'net/http'");
452    let _ = writeln!(out, "      require 'uri'");
453    let _ = writeln!(out, "      require 'json'");
454    let _ = writeln!(
455        out,
456        "      _uri = URI.parse(\"#{{mock_server_url}}/fixtures/{fixture_id}\")"
457    );
458    let _ = writeln!(out, "      _http = Net::HTTP.new(_uri.host, _uri.port)");
459    let _ = writeln!(out, "      _http.use_ssl = _uri.scheme == 'https'");
460    // Disable automatic redirect following so 3xx status codes can be asserted.
461    let _ = writeln!(out, "      _req = Net::HTTP::{method_class}.new(_uri.request_uri)");
462
463    let has_body = req
464        .body
465        .as_ref()
466        .is_some_and(|b| !matches!(b, serde_json::Value::String(s) if s.is_empty()));
467    if has_body {
468        let ruby_body = json_to_ruby(req.body.as_ref().unwrap());
469        let _ = writeln!(out, "      _req.body = {ruby_body}.to_json");
470        let _ = writeln!(out, "      _req['Content-Type'] = 'application/json'");
471    }
472
473    for (k, v) in &req.headers {
474        // Skip Content-Type when already set from the body above to avoid duplicates.
475        if has_body && k.to_lowercase() == "content-type" {
476            continue;
477        }
478        let rk = ruby_string_literal(k);
479        let rv = ruby_string_literal(v);
480        let _ = writeln!(out, "      _req[{rk}] = {rv}");
481    }
482
483    let _ = writeln!(out, "      response = _http.request(_req)");
484}
485
486/// Emit body assertions for an HTTP expected response.
487fn render_ruby_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
488    if let Some(body) = &expected.body {
489        match body {
490            // Empty string body: 204 No Content or empty response — no body assertion.
491            serde_json::Value::String(s) if s.is_empty() => {}
492            // Null body: no assertion.
493            serde_json::Value::Null => {}
494            // Plain string body: mock server sends it as raw text, not JSON-encoded.
495            serde_json::Value::String(s) => {
496                let ruby_val = ruby_string_literal(s);
497                let _ = writeln!(out, "      expect(response.body).to eq({ruby_val})");
498            }
499            _ => {
500                let ruby_val = json_to_ruby(body);
501                // response.body may be nil for 204 No Content or empty responses.
502                let _ = writeln!(
503                    out,
504                    "      _body = response.body && !response.body.empty? ? JSON.parse(response.body) : nil"
505                );
506                let _ = writeln!(out, "      expect(_body).to eq({ruby_val})");
507            }
508        }
509    }
510    if let Some(partial) = &expected.body_partial {
511        if let Some(obj) = partial.as_object() {
512            let _ = writeln!(out, "      _body = JSON.parse(response.body)");
513            for (key, val) in obj {
514                let ruby_key = ruby_string_literal(key);
515                let ruby_val = json_to_ruby(val);
516                let _ = writeln!(out, "      expect(_body[{ruby_key}]).to eq({ruby_val})");
517            }
518        }
519    }
520    if let Some(errors) = &expected.validation_errors {
521        if expected.body.is_none() {
522            // Only check validation_errors when no full body assertion is present.
523            for err in errors {
524                let msg_lit = ruby_string_literal(&err.msg);
525                let _ = writeln!(out, "      _body = JSON.parse(response.body)");
526                let _ = writeln!(out, "      _errors = _body['errors'] || []");
527                let _ = writeln!(
528                    out,
529                    "      expect(_errors.map {{ |e| e['msg'] }}).to include({msg_lit})"
530                );
531            }
532        }
533    }
534}
535
536/// Emit header assertions for an HTTP expected response.
537///
538/// Special tokens:
539/// - `"<<present>>"` — assert the header key exists
540/// - `"<<absent>>"` — assert the header key is absent
541/// - `"<<uuid>>"` — assert the header value matches a UUID regex
542fn render_ruby_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
543    for (name, value) in &expected.headers {
544        let header_key = name.to_lowercase();
545        // The mock server serves uncompressed bodies, so content-encoding is never set.
546        // Skip this assertion to avoid false failures.
547        if header_key == "content-encoding" {
548            continue;
549        }
550        // Net::HTTP response headers are accessed via response[key]
551        let header_expr = format!("response[{}]", ruby_string_literal(&header_key));
552        match value.as_str() {
553            "<<present>>" => {
554                let _ = writeln!(out, "      expect({header_expr}).not_to be_nil");
555            }
556            "<<absent>>" => {
557                let _ = writeln!(out, "      expect({header_expr}).to be_nil");
558            }
559            "<<uuid>>" => {
560                let _ = writeln!(
561                    out,
562                    "      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)"
563                );
564            }
565            literal => {
566                let ruby_val = ruby_string_literal(literal);
567                let _ = writeln!(out, "      expect({header_expr}).to eq({ruby_val})");
568            }
569        }
570    }
571}
572
573// ---------------------------------------------------------------------------
574// Function-call test rendering
575// ---------------------------------------------------------------------------
576
577#[allow(clippy::too_many_arguments)]
578fn render_example(
579    out: &mut String,
580    fixture: &Fixture,
581    function_name: &str,
582    call_receiver: &str,
583    result_var: &str,
584    args: &[crate::config::ArgMapping],
585    field_resolver: &FieldResolver,
586    options_type: Option<&str>,
587    enum_fields: &HashMap<String, String>,
588    result_is_simple: bool,
589    e2e_config: &E2eConfig,
590) {
591    let test_name = sanitize_ident(&fixture.id);
592    let description = fixture.description.replace('\'', "\\'");
593    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
594
595    let (mut setup_lines, args_str) = build_args_and_setup(
596        &fixture.input,
597        args,
598        call_receiver,
599        options_type,
600        enum_fields,
601        result_is_simple,
602        &fixture.id,
603    );
604
605    // Build visitor if present and add to setup
606    let mut visitor_arg = String::new();
607    if let Some(visitor_spec) = &fixture.visitor {
608        visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
609    }
610
611    let final_args = if visitor_arg.is_empty() {
612        args_str
613    } else if args_str.is_empty() {
614        visitor_arg
615    } else {
616        format!("{args_str}, {visitor_arg}")
617    };
618
619    let call_expr = format!("{call_receiver}.{function_name}({final_args})");
620
621    let _ = writeln!(out, "  it '{test_name}: {description}' do");
622
623    for line in &setup_lines {
624        let _ = writeln!(out, "    {line}");
625    }
626
627    if expects_error {
628        let _ = writeln!(out, "    expect {{ {call_expr} }}.to raise_error");
629        let _ = writeln!(out, "  end");
630        return;
631    }
632
633    // Check if any non-error assertion actually uses the result variable.
634    let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
635    let _ = writeln!(out, "    {result_var} = {call_expr}");
636
637    for assertion in &fixture.assertions {
638        render_assertion(out, assertion, result_var, field_resolver, result_is_simple, e2e_config);
639    }
640
641    // When all assertions were skipped (fields unavailable), the example has no
642    // expect() calls, which triggers rubocop's RSpec/NoExpectationExample cop.
643    // Emit a minimal placeholder expectation so rubocop is satisfied.
644    if !has_usable {
645        let _ = writeln!(out, "    expect({result_var}).not_to be_nil");
646    }
647
648    let _ = writeln!(out, "  end");
649}
650
651/// Build setup lines (e.g. handle creation) and the argument list for the function call.
652///
653/// Returns `(setup_lines, args_string)`.
654fn build_args_and_setup(
655    input: &serde_json::Value,
656    args: &[crate::config::ArgMapping],
657    call_receiver: &str,
658    options_type: Option<&str>,
659    enum_fields: &HashMap<String, String>,
660    result_is_simple: bool,
661    fixture_id: &str,
662) -> (Vec<String>, String) {
663    if args.is_empty() {
664        return (Vec::new(), json_to_ruby(input));
665    }
666
667    let mut setup_lines: Vec<String> = Vec::new();
668    let mut parts: Vec<String> = Vec::new();
669
670    for arg in args {
671        if arg.arg_type == "mock_url" {
672            setup_lines.push(format!(
673                "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
674                arg.name,
675            ));
676            parts.push(arg.name.clone());
677            continue;
678        }
679
680        if arg.arg_type == "handle" {
681            // Generate a create_engine (or equivalent) call and pass the variable.
682            let constructor_name = format!("create_{}", arg.name.to_snake_case());
683            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
684            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
685            if config_value.is_null()
686                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
687            {
688                setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
689            } else {
690                let literal = json_to_ruby(config_value);
691                let name = &arg.name;
692                setup_lines.push(format!("{name}_config = {literal}"));
693                setup_lines.push(format!(
694                    "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
695                    arg.name,
696                    name = name,
697                ));
698            }
699            parts.push(arg.name.clone());
700            continue;
701        }
702
703        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
704        let val = input.get(field);
705        match val {
706            None | Some(serde_json::Value::Null) if arg.optional => {
707                // Optional arg with no fixture value: skip entirely.
708                continue;
709            }
710            None | Some(serde_json::Value::Null) => {
711                // Required arg with no fixture value: pass a language-appropriate default.
712                let default_val = match arg.arg_type.as_str() {
713                    "string" => "''".to_string(),
714                    "int" | "integer" => "0".to_string(),
715                    "float" | "number" => "0.0".to_string(),
716                    "bool" | "boolean" => "false".to_string(),
717                    _ => "nil".to_string(),
718                };
719                parts.push(default_val);
720            }
721            Some(v) => {
722                // For json_object args with options_type, construct a typed options object.
723                // When result_is_simple, the binding accepts a plain Hash (no wrapper class).
724                if arg.arg_type == "json_object" && !v.is_null() {
725                    if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
726                        let kwargs: Vec<String> = obj
727                            .iter()
728                            .map(|(k, vv)| {
729                                let snake_key = k.to_snake_case();
730                                let rb_val = if enum_fields.contains_key(k) {
731                                    if let Some(s) = vv.as_str() {
732                                        let snake_val = s.to_snake_case();
733                                        format!("'{snake_val}'")
734                                    } else {
735                                        json_to_ruby(vv)
736                                    }
737                                } else {
738                                    json_to_ruby(vv)
739                                };
740                                format!("{snake_key}: {rb_val}")
741                            })
742                            .collect();
743                        if result_is_simple {
744                            parts.push(format!("{{{}}}", kwargs.join(", ")));
745                        } else {
746                            parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
747                        }
748                        continue;
749                    }
750                }
751                parts.push(json_to_ruby(v));
752            }
753        }
754    }
755
756    (setup_lines, parts.join(", "))
757}
758
759fn render_assertion(
760    out: &mut String,
761    assertion: &Assertion,
762    result_var: &str,
763    field_resolver: &FieldResolver,
764    result_is_simple: bool,
765    e2e_config: &E2eConfig,
766) {
767    // Handle synthetic / derived fields before the is_valid_for_result check
768    // so they are never treated as struct attribute accesses on the result.
769    if let Some(f) = &assertion.field {
770        match f.as_str() {
771            "chunks_have_content" => {
772                let pred = format!("({result_var}.chunks || []).all? {{ |c| c.content && !c.content.empty? }}");
773                match assertion.assertion_type.as_str() {
774                    "is_true" => {
775                        let _ = writeln!(out, "    expect({pred}).to be(true)");
776                    }
777                    "is_false" => {
778                        let _ = writeln!(out, "    expect({pred}).to be(false)");
779                    }
780                    _ => {
781                        let _ = writeln!(
782                            out,
783                            "    # skipped: unsupported assertion type on synthetic field '{f}'"
784                        );
785                    }
786                }
787                return;
788            }
789            "chunks_have_embeddings" => {
790                let pred =
791                    format!("({result_var}.chunks || []).all? {{ |c| !c.embedding.nil? && !c.embedding.empty? }}");
792                match assertion.assertion_type.as_str() {
793                    "is_true" => {
794                        let _ = writeln!(out, "    expect({pred}).to be(true)");
795                    }
796                    "is_false" => {
797                        let _ = writeln!(out, "    expect({pred}).to be(false)");
798                    }
799                    _ => {
800                        let _ = writeln!(
801                            out,
802                            "    # skipped: unsupported assertion type on synthetic field '{f}'"
803                        );
804                    }
805                }
806                return;
807            }
808            // ---- EmbedResponse virtual fields ----
809            // embed_texts returns Array<Array<Float>> in Ruby — no wrapper struct.
810            // result_var is the embedding matrix; use it directly.
811            "embeddings" => {
812                match assertion.assertion_type.as_str() {
813                    "count_equals" => {
814                        if let Some(val) = &assertion.value {
815                            let rb_val = json_to_ruby(val);
816                            let _ = writeln!(out, "    expect({result_var}.length).to eq({rb_val})");
817                        }
818                    }
819                    "count_min" => {
820                        if let Some(val) = &assertion.value {
821                            let rb_val = json_to_ruby(val);
822                            let _ = writeln!(out, "    expect({result_var}.length).to be >= {rb_val}");
823                        }
824                    }
825                    "not_empty" => {
826                        let _ = writeln!(out, "    expect({result_var}).not_to be_empty");
827                    }
828                    "is_empty" => {
829                        let _ = writeln!(out, "    expect({result_var}).to be_empty");
830                    }
831                    _ => {
832                        let _ = writeln!(
833                            out,
834                            "    # skipped: unsupported assertion type on synthetic field 'embeddings'"
835                        );
836                    }
837                }
838                return;
839            }
840            "embedding_dimensions" => {
841                let expr = format!("({result_var}.empty? ? 0 : {result_var}[0].length)");
842                match assertion.assertion_type.as_str() {
843                    "equals" => {
844                        if let Some(val) = &assertion.value {
845                            let rb_val = json_to_ruby(val);
846                            let _ = writeln!(out, "    expect({expr}).to eq({rb_val})");
847                        }
848                    }
849                    "greater_than" => {
850                        if let Some(val) = &assertion.value {
851                            let rb_val = json_to_ruby(val);
852                            let _ = writeln!(out, "    expect({expr}).to be > {rb_val}");
853                        }
854                    }
855                    _ => {
856                        let _ = writeln!(
857                            out,
858                            "    # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
859                        );
860                    }
861                }
862                return;
863            }
864            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
865                let pred = match f.as_str() {
866                    "embeddings_valid" => {
867                        format!("{result_var}.all? {{ |e| !e.empty? }}")
868                    }
869                    "embeddings_finite" => {
870                        format!("{result_var}.all? {{ |e| e.all? {{ |v| v.finite? }} }}")
871                    }
872                    "embeddings_non_zero" => {
873                        format!("{result_var}.all? {{ |e| e.any? {{ |v| v != 0.0 }} }}")
874                    }
875                    "embeddings_normalized" => {
876                        format!("{result_var}.all? {{ |e| n = e.sum {{ |v| v * v }}; (n - 1.0).abs < 1e-3 }}")
877                    }
878                    _ => unreachable!(),
879                };
880                match assertion.assertion_type.as_str() {
881                    "is_true" => {
882                        let _ = writeln!(out, "    expect({pred}).to be(true)");
883                    }
884                    "is_false" => {
885                        let _ = writeln!(out, "    expect({pred}).to be(false)");
886                    }
887                    _ => {
888                        let _ = writeln!(
889                            out,
890                            "    # skipped: unsupported assertion type on synthetic field '{f}'"
891                        );
892                    }
893                }
894                return;
895            }
896            // ---- keywords / keywords_count ----
897            // Ruby ExtractionResult does not expose extracted_keywords; skip.
898            "keywords" | "keywords_count" => {
899                let _ = writeln!(out, "    # skipped: field '{f}' not available on Ruby ExtractionResult");
900                return;
901            }
902            _ => {}
903        }
904    }
905
906    // Skip assertions on fields that don't exist on the result type.
907    if let Some(f) = &assertion.field {
908        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
909            let _ = writeln!(out, "    # skipped: field '{f}' not available on result type");
910            return;
911        }
912    }
913
914    // When result_is_simple, skip assertions that reference non-content fields.
915    if result_is_simple {
916        if let Some(f) = &assertion.field {
917            let f_lower = f.to_lowercase();
918            if !f.is_empty()
919                && f_lower != "content"
920                && (f_lower.starts_with("metadata")
921                    || f_lower.starts_with("document")
922                    || f_lower.starts_with("structure"))
923            {
924                return;
925            }
926        }
927    }
928
929    let field_expr = if result_is_simple {
930        result_var.to_string()
931    } else {
932        match &assertion.field {
933            Some(f) if !f.is_empty() => field_resolver.accessor(f, "ruby", result_var),
934            _ => result_var.to_string(),
935        }
936    };
937
938    // For string equality, strip trailing whitespace to handle trailing newlines
939    // from the converter.
940    let stripped_field_expr = if result_is_simple {
941        format!("{field_expr}.strip")
942    } else {
943        field_expr.clone()
944    };
945
946    match assertion.assertion_type.as_str() {
947        "equals" => {
948            if let Some(expected) = &assertion.value {
949                // Use be(true)/be(false) for booleans (RSpec/BeEq).
950                if let Some(b) = expected.as_bool() {
951                    let _ = writeln!(out, "    expect({stripped_field_expr}).to be({b})");
952                } else {
953                    let rb_val = json_to_ruby(expected);
954                    let _ = writeln!(out, "    expect({stripped_field_expr}).to eq({rb_val})");
955                }
956            }
957        }
958        "contains" => {
959            if let Some(expected) = &assertion.value {
960                let rb_val = json_to_ruby(expected);
961                // Use .to_s to handle both String and Symbol (enum) fields
962                let _ = writeln!(out, "    expect({field_expr}.to_s).to include({rb_val})");
963            }
964        }
965        "contains_all" => {
966            if let Some(values) = &assertion.values {
967                for val in values {
968                    let rb_val = json_to_ruby(val);
969                    let _ = writeln!(out, "    expect({field_expr}.to_s).to include({rb_val})");
970                }
971            }
972        }
973        "not_contains" => {
974            if let Some(expected) = &assertion.value {
975                let rb_val = json_to_ruby(expected);
976                let _ = writeln!(out, "    expect({field_expr}.to_s).not_to include({rb_val})");
977            }
978        }
979        "not_empty" => {
980            let _ = writeln!(out, "    expect({field_expr}).not_to be_empty");
981        }
982        "is_empty" => {
983            // Handle nil (None) as empty for optional fields
984            let _ = writeln!(out, "    expect({field_expr}.nil? || {field_expr}.empty?).to be(true)");
985        }
986        "contains_any" => {
987            if let Some(values) = &assertion.values {
988                let items: Vec<String> = values.iter().map(json_to_ruby).collect();
989                let arr_str = items.join(", ");
990                let _ = writeln!(
991                    out,
992                    "    expect([{arr_str}].any? {{ |v| {field_expr}.to_s.include?(v) }}).to be(true)"
993                );
994            }
995        }
996        "greater_than" => {
997            if let Some(val) = &assertion.value {
998                let rb_val = json_to_ruby(val);
999                let _ = writeln!(out, "    expect({field_expr}).to be > {rb_val}");
1000            }
1001        }
1002        "less_than" => {
1003            if let Some(val) = &assertion.value {
1004                let rb_val = json_to_ruby(val);
1005                let _ = writeln!(out, "    expect({field_expr}).to be < {rb_val}");
1006            }
1007        }
1008        "greater_than_or_equal" => {
1009            if let Some(val) = &assertion.value {
1010                let rb_val = json_to_ruby(val);
1011                let _ = writeln!(out, "    expect({field_expr}).to be >= {rb_val}");
1012            }
1013        }
1014        "less_than_or_equal" => {
1015            if let Some(val) = &assertion.value {
1016                let rb_val = json_to_ruby(val);
1017                let _ = writeln!(out, "    expect({field_expr}).to be <= {rb_val}");
1018            }
1019        }
1020        "starts_with" => {
1021            if let Some(expected) = &assertion.value {
1022                let rb_val = json_to_ruby(expected);
1023                let _ = writeln!(out, "    expect({field_expr}).to start_with({rb_val})");
1024            }
1025        }
1026        "ends_with" => {
1027            if let Some(expected) = &assertion.value {
1028                let rb_val = json_to_ruby(expected);
1029                let _ = writeln!(out, "    expect({field_expr}).to end_with({rb_val})");
1030            }
1031        }
1032        "min_length" => {
1033            if let Some(val) = &assertion.value {
1034                if let Some(n) = val.as_u64() {
1035                    let _ = writeln!(out, "    expect({field_expr}.length).to be >= {n}");
1036                }
1037            }
1038        }
1039        "max_length" => {
1040            if let Some(val) = &assertion.value {
1041                if let Some(n) = val.as_u64() {
1042                    let _ = writeln!(out, "    expect({field_expr}.length).to be <= {n}");
1043                }
1044            }
1045        }
1046        "count_min" => {
1047            if let Some(val) = &assertion.value {
1048                if let Some(n) = val.as_u64() {
1049                    let _ = writeln!(out, "    expect({field_expr}.length).to be >= {n}");
1050                }
1051            }
1052        }
1053        "count_equals" => {
1054            if let Some(val) = &assertion.value {
1055                if let Some(n) = val.as_u64() {
1056                    let _ = writeln!(out, "    expect({field_expr}.length).to eq({n})");
1057                }
1058            }
1059        }
1060        "is_true" => {
1061            let _ = writeln!(out, "    expect({field_expr}).to be true");
1062        }
1063        "is_false" => {
1064            let _ = writeln!(out, "    expect({field_expr}).to be false");
1065        }
1066        "method_result" => {
1067            if let Some(method_name) = &assertion.method {
1068                // Derive call_receiver for module-level helper calls.
1069                let lang = "ruby";
1070                let call = &e2e_config.call;
1071                let overrides = call.overrides.get(lang);
1072                let module_path = overrides
1073                    .and_then(|o| o.module.as_ref())
1074                    .cloned()
1075                    .unwrap_or_else(|| call.module.clone());
1076                let call_receiver = ruby_module_name(&module_path);
1077
1078                let call_expr =
1079                    build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
1080                let check = assertion.check.as_deref().unwrap_or("is_true");
1081                match check {
1082                    "equals" => {
1083                        if let Some(val) = &assertion.value {
1084                            if let Some(b) = val.as_bool() {
1085                                let _ = writeln!(out, "    expect({call_expr}).to be {b}");
1086                            } else {
1087                                let rb_val = json_to_ruby(val);
1088                                let _ = writeln!(out, "    expect({call_expr}).to eq({rb_val})");
1089                            }
1090                        }
1091                    }
1092                    "is_true" => {
1093                        let _ = writeln!(out, "    expect({call_expr}).to be true");
1094                    }
1095                    "is_false" => {
1096                        let _ = writeln!(out, "    expect({call_expr}).to be false");
1097                    }
1098                    "greater_than_or_equal" => {
1099                        if let Some(val) = &assertion.value {
1100                            let rb_val = json_to_ruby(val);
1101                            let _ = writeln!(out, "    expect({call_expr}).to be >= {rb_val}");
1102                        }
1103                    }
1104                    "count_min" => {
1105                        if let Some(val) = &assertion.value {
1106                            let n = val.as_u64().unwrap_or(0);
1107                            let _ = writeln!(out, "    expect({call_expr}.length).to be >= {n}");
1108                        }
1109                    }
1110                    "is_error" => {
1111                        let _ = writeln!(out, "    expect {{ {call_expr} }}.to raise_error");
1112                    }
1113                    "contains" => {
1114                        if let Some(val) = &assertion.value {
1115                            let rb_val = json_to_ruby(val);
1116                            let _ = writeln!(out, "    expect({call_expr}).to include({rb_val})");
1117                        }
1118                    }
1119                    other_check => {
1120                        panic!("Ruby e2e generator: unsupported method_result check type: {other_check}");
1121                    }
1122                }
1123            } else {
1124                panic!("Ruby e2e generator: method_result assertion missing 'method' field");
1125            }
1126        }
1127        "matches_regex" => {
1128            if let Some(expected) = &assertion.value {
1129                let rb_val = json_to_ruby(expected);
1130                let _ = writeln!(out, "    expect({field_expr}).to match({rb_val})");
1131            }
1132        }
1133        "not_error" => {
1134            // Already handled by the call succeeding without exception.
1135        }
1136        "error" => {
1137            // Handled at the example level.
1138        }
1139        other => {
1140            panic!("Ruby e2e generator: unsupported assertion type: {other}");
1141        }
1142    }
1143}
1144
1145/// Build a Ruby call expression for a `method_result` assertion on a tree-sitter Tree.
1146/// Maps method names to the appropriate Ruby method or module-function calls.
1147fn build_ruby_method_call(
1148    call_receiver: &str,
1149    result_var: &str,
1150    method_name: &str,
1151    args: Option<&serde_json::Value>,
1152) -> String {
1153    match method_name {
1154        "root_child_count" => format!("{result_var}.root_node.child_count"),
1155        "root_node_type" => format!("{result_var}.root_node.type"),
1156        "named_children_count" => format!("{result_var}.root_node.named_child_count"),
1157        "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
1158        "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
1159        "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
1160        "contains_node_type" => {
1161            let node_type = args
1162                .and_then(|a| a.get("node_type"))
1163                .and_then(|v| v.as_str())
1164                .unwrap_or("");
1165            format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
1166        }
1167        "find_nodes_by_type" => {
1168            let node_type = args
1169                .and_then(|a| a.get("node_type"))
1170                .and_then(|v| v.as_str())
1171                .unwrap_or("");
1172            format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
1173        }
1174        "run_query" => {
1175            let query_source = args
1176                .and_then(|a| a.get("query_source"))
1177                .and_then(|v| v.as_str())
1178                .unwrap_or("");
1179            let language = args
1180                .and_then(|a| a.get("language"))
1181                .and_then(|v| v.as_str())
1182                .unwrap_or("");
1183            format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1184        }
1185        _ => format!("{result_var}.{method_name}"),
1186    }
1187}
1188
1189/// Convert a module path (e.g., "html_to_markdown") to Ruby PascalCase module name
1190/// (e.g., "HtmlToMarkdown").
1191fn ruby_module_name(module_path: &str) -> String {
1192    use heck::ToUpperCamelCase;
1193    module_path.to_upper_camel_case()
1194}
1195
1196/// Convert a `serde_json::Value` to a Ruby literal string, preferring single quotes.
1197fn json_to_ruby(value: &serde_json::Value) -> String {
1198    match value {
1199        serde_json::Value::String(s) => ruby_string_literal(s),
1200        serde_json::Value::Bool(true) => "true".to_string(),
1201        serde_json::Value::Bool(false) => "false".to_string(),
1202        serde_json::Value::Number(n) => n.to_string(),
1203        serde_json::Value::Null => "nil".to_string(),
1204        serde_json::Value::Array(arr) => {
1205            let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
1206            format!("[{}]", items.join(", "))
1207        }
1208        serde_json::Value::Object(map) => {
1209            let items: Vec<String> = map
1210                .iter()
1211                .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
1212                .collect();
1213            format!("{{ {} }}", items.join(", "))
1214        }
1215    }
1216}
1217
1218// ---------------------------------------------------------------------------
1219// Visitor generation
1220// ---------------------------------------------------------------------------
1221
1222/// Build a Ruby visitor object and add setup lines. Returns the visitor expression.
1223fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1224    setup_lines.push("visitor = Class.new do".to_string());
1225    for (method_name, action) in &visitor_spec.callbacks {
1226        emit_ruby_visitor_method(setup_lines, method_name, action);
1227    }
1228    setup_lines.push("end.new".to_string());
1229    "visitor".to_string()
1230}
1231
1232/// Emit a Ruby visitor method for a callback action.
1233fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1234    let snake_method = method_name;
1235    let params = match method_name {
1236        "visit_link" => "ctx, href, text, title",
1237        "visit_image" => "ctx, src, alt, title",
1238        "visit_heading" => "ctx, level, text, id",
1239        "visit_code_block" => "ctx, lang, code",
1240        "visit_code_inline"
1241        | "visit_strong"
1242        | "visit_emphasis"
1243        | "visit_strikethrough"
1244        | "visit_underline"
1245        | "visit_subscript"
1246        | "visit_superscript"
1247        | "visit_mark"
1248        | "visit_button"
1249        | "visit_summary"
1250        | "visit_figcaption"
1251        | "visit_definition_term"
1252        | "visit_definition_description" => "ctx, text",
1253        "visit_text" => "ctx, text",
1254        "visit_list_item" => "ctx, ordered, marker, text",
1255        "visit_blockquote" => "ctx, content, depth",
1256        "visit_table_row" => "ctx, cells, is_header",
1257        "visit_custom_element" => "ctx, tag_name, html",
1258        "visit_form" => "ctx, action_url, method",
1259        "visit_input" => "ctx, input_type, name, value",
1260        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1261        "visit_details" => "ctx, is_open",
1262        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1263        "visit_list_start" => "ctx, ordered",
1264        "visit_list_end" => "ctx, ordered, output",
1265        _ => "ctx",
1266    };
1267
1268    setup_lines.push(format!("  def {snake_method}({params})"));
1269    match action {
1270        CallbackAction::Skip => {
1271            setup_lines.push("    'skip'".to_string());
1272        }
1273        CallbackAction::Continue => {
1274            setup_lines.push("    'continue'".to_string());
1275        }
1276        CallbackAction::PreserveHtml => {
1277            setup_lines.push("    'preserve_html'".to_string());
1278        }
1279        CallbackAction::Custom { output } => {
1280            let escaped = ruby_string_literal(output);
1281            setup_lines.push(format!("    {{ custom: {escaped} }}"));
1282        }
1283        CallbackAction::CustomTemplate { template } => {
1284            let escaped = ruby_string_literal(template);
1285            setup_lines.push(format!("    {{ custom: {escaped} }}"));
1286        }
1287    }
1288    setup_lines.push("  end".to_string());
1289}