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 anyhow::Result;
15use heck::ToSnakeCase;
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21
22/// Ruby e2e code generator.
23pub struct RubyCodegen;
24
25impl E2eCodegen for RubyCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        alef_config: &AlefConfig,
31    ) -> Result<Vec<GeneratedFile>> {
32        let lang = self.language_name();
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35        let mut files = Vec::new();
36
37        // Resolve call config with overrides.
38        let call = &e2e_config.call;
39        let overrides = call.overrides.get(lang);
40        let module_path = overrides
41            .and_then(|o| o.module.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.module.clone());
44        let function_name = overrides
45            .and_then(|o| o.function.as_ref())
46            .cloned()
47            .unwrap_or_else(|| call.function.clone());
48        let class_name = overrides.and_then(|o| o.class.as_ref()).cloned();
49        let options_type = overrides.and_then(|o| o.options_type.clone());
50        let empty_enum_fields = HashMap::new();
51        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
52        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
53        let result_var = &call.result_var;
54
55        // Resolve package config.
56        let ruby_pkg = e2e_config.resolve_package("ruby");
57        let gem_name = ruby_pkg
58            .as_ref()
59            .and_then(|p| p.name.as_ref())
60            .cloned()
61            .unwrap_or_else(|| alef_config.crate_config.name.replace('-', "_"));
62        let gem_path = ruby_pkg
63            .as_ref()
64            .and_then(|p| p.path.as_ref())
65            .cloned()
66            .unwrap_or_else(|| "../../packages/ruby".to_string());
67        let gem_version = ruby_pkg
68            .as_ref()
69            .and_then(|p| p.version.as_ref())
70            .cloned()
71            .unwrap_or_else(|| "0.1.0".to_string());
72
73        // Generate Gemfile.
74        files.push(GeneratedFile {
75            path: output_base.join("Gemfile"),
76            content: render_gemfile(&gem_name, &gem_path, &gem_version, e2e_config.dep_mode),
77            generated_header: false,
78        });
79
80        // Generate .rubocop.yaml for linting generated specs.
81        files.push(GeneratedFile {
82            path: output_base.join(".rubocop.yaml"),
83            content: render_rubocop_yaml(),
84            generated_header: false,
85        });
86
87        // Generate spec files per category.
88        let spec_base = output_base.join("spec");
89
90        for group in groups {
91            let active: Vec<&Fixture> = group
92                .fixtures
93                .iter()
94                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
95                .collect();
96
97            if active.is_empty() {
98                continue;
99            }
100
101            let field_resolver_pre = FieldResolver::new(
102                &e2e_config.fields,
103                &e2e_config.fields_optional,
104                &e2e_config.result_fields,
105                &e2e_config.fields_array,
106            );
107            // Skip the entire file if no fixture in this category produces output.
108            let has_any_output = active.iter().any(|f| {
109                // HTTP tests always produce output.
110                if f.is_http_test() {
111                    return true;
112                }
113                let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
114                expects_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
115            });
116            if !has_any_output {
117                continue;
118            }
119
120            let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
121            let field_resolver = FieldResolver::new(
122                &e2e_config.fields,
123                &e2e_config.fields_optional,
124                &e2e_config.result_fields,
125                &e2e_config.fields_array,
126            );
127            let content = render_spec_file(
128                &group.category,
129                &active,
130                &module_path,
131                &function_name,
132                class_name.as_deref(),
133                result_var,
134                &gem_name,
135                &e2e_config.call.args,
136                &field_resolver,
137                options_type.as_deref(),
138                enum_fields,
139                result_is_simple,
140            );
141            files.push(GeneratedFile {
142                path: spec_base.join(filename),
143                content,
144                generated_header: true,
145            });
146        }
147
148        Ok(files)
149    }
150
151    fn language_name(&self) -> &'static str {
152        "ruby"
153    }
154}
155
156// ---------------------------------------------------------------------------
157// Rendering
158// ---------------------------------------------------------------------------
159
160fn render_gemfile(
161    gem_name: &str,
162    gem_path: &str,
163    gem_version: &str,
164    dep_mode: crate::config::DependencyMode,
165) -> String {
166    let gem_line = match dep_mode {
167        crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
168        crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
169    };
170    format!(
171        "# frozen_string_literal: true\n\
172         \n\
173         source 'https://rubygems.org'\n\
174         \n\
175         {gem_line}\n\
176         gem 'rspec', '~> 3.13'\n\
177         gem 'rubocop', '~> 1.86'\n\
178         gem 'rubocop-rspec', '~> 3.9'\n\
179         gem 'faraday', '~> 2.0'\n"
180    )
181}
182
183fn render_rubocop_yaml() -> String {
184    r#"# Generated by alef e2e — do not edit.
185AllCops:
186  NewCops: enable
187  TargetRubyVersion: 3.2
188  SuggestExtensions: false
189
190plugins:
191  - rubocop-rspec
192
193# --- Justified suppressions for generated test code ---
194
195# Generated tests are verbose by nature (setup + multiple assertions).
196Metrics/BlockLength:
197  Enabled: false
198Metrics/MethodLength:
199  Enabled: false
200Layout/LineLength:
201  Enabled: false
202
203# Generated tests use multiple assertions per example for thorough verification.
204RSpec/MultipleExpectations:
205  Enabled: false
206RSpec/ExampleLength:
207  Enabled: false
208
209# Generated tests describe categories as strings, not classes.
210RSpec/DescribeClass:
211  Enabled: false
212
213# Fixture-driven tests may produce identical assertion bodies for different inputs.
214RSpec/RepeatedExample:
215  Enabled: false
216
217# Error-handling tests use bare raise_error (exception type not known at generation time).
218RSpec/UnspecifiedException:
219  Enabled: false
220"#
221    .to_string()
222}
223
224#[allow(clippy::too_many_arguments)]
225fn render_spec_file(
226    category: &str,
227    fixtures: &[&Fixture],
228    module_path: &str,
229    function_name: &str,
230    class_name: Option<&str>,
231    result_var: &str,
232    gem_name: &str,
233    args: &[crate::config::ArgMapping],
234    field_resolver: &FieldResolver,
235    options_type: Option<&str>,
236    enum_fields: &HashMap<String, String>,
237    result_is_simple: bool,
238) -> String {
239    let mut out = String::new();
240    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
241    let _ = writeln!(out, "# frozen_string_literal: true");
242    let _ = writeln!(out);
243
244    // Require the gem (single quotes).
245    let require_name = if module_path.is_empty() { gem_name } else { module_path };
246    let _ = writeln!(out, "require '{}'", require_name.replace('-', "_"));
247    let _ = writeln!(out, "require 'json'");
248
249    // Add faraday if any fixture is an HTTP test.
250    let has_http = fixtures.iter().any(|f| f.is_http_test());
251    if has_http {
252        let _ = writeln!(out, "require 'faraday'");
253    }
254    let _ = writeln!(out);
255
256    // Build the Ruby module/class qualifier for calls.
257    let call_receiver = class_name
258        .map(|s| s.to_string())
259        .unwrap_or_else(|| ruby_module_name(module_path));
260
261    let _ = writeln!(out, "RSpec.describe '{}' do", category);
262
263    // Emit a shared client helper when there are HTTP tests.
264    if has_http {
265        let _ = writeln!(
266            out,
267            "  let(:base_url) {{ ENV.fetch('TEST_SERVER_URL', 'http://localhost:8080') }}"
268        );
269        let _ = writeln!(out, "  let(:client) do");
270        let _ = writeln!(out, "    Faraday.new(url: base_url) do |f|");
271        let _ = writeln!(out, "      f.request :json");
272        let _ = writeln!(out, "      f.response :json, content_type: /\\bjson$/");
273        let _ = writeln!(out, "    end");
274        let _ = writeln!(out, "  end");
275        let _ = writeln!(out);
276    }
277
278    let mut first = true;
279    for fixture in fixtures {
280        if !first {
281            let _ = writeln!(out);
282        }
283        first = false;
284
285        if let Some(http) = &fixture.http {
286            render_http_example(&mut out, fixture, http);
287        } else {
288            render_example(
289                &mut out,
290                fixture,
291                function_name,
292                &call_receiver,
293                result_var,
294                args,
295                field_resolver,
296                options_type,
297                enum_fields,
298                result_is_simple,
299            );
300        }
301    }
302
303    let _ = writeln!(out, "end");
304    out
305}
306
307/// Check if a fixture has at least one assertion that will produce an executable
308/// expect() call (not just a skip comment).
309fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
310    fixture.assertions.iter().any(|a| {
311        // not_error is implicit (call succeeding), error is handled separately.
312        if a.assertion_type == "not_error" || a.assertion_type == "error" {
313            return false;
314        }
315        // Check field validity.
316        if let Some(f) = &a.field {
317            if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
318                return false;
319            }
320            // When result_is_simple, skip non-content fields.
321            if result_is_simple {
322                let f_lower = f.to_lowercase();
323                if !f.is_empty()
324                    && f_lower != "content"
325                    && (f_lower.starts_with("metadata")
326                        || f_lower.starts_with("document")
327                        || f_lower.starts_with("structure"))
328                {
329                    return false;
330                }
331            }
332        }
333        true
334    })
335}
336
337// ---------------------------------------------------------------------------
338// HTTP test rendering
339// ---------------------------------------------------------------------------
340
341/// Render an RSpec `describe` + `it` block for an HTTP server test fixture.
342fn render_http_example(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
343    let description = fixture.description.replace('\'', "\\'");
344    let method = http.request.method.to_uppercase();
345    let path = &http.request.path;
346
347    let _ = writeln!(out, "  describe '{method} {path}' do");
348    let _ = writeln!(out, "    it '{}' do", description);
349
350    // Build request call.
351    render_ruby_http_request(out, &http.request);
352
353    // Assert status.
354    let status = http.expected_response.status_code;
355    let _ = writeln!(out, "      expect(response.status).to eq({status})");
356
357    // Assert response body.
358    render_ruby_body_assertions(out, &http.expected_response);
359
360    // Assert response headers.
361    render_ruby_header_assertions(out, &http.expected_response);
362
363    let _ = writeln!(out, "    end");
364    let _ = writeln!(out, "  end");
365}
366
367/// Emit the Faraday request lines inside an RSpec example.
368fn render_ruby_http_request(out: &mut String, req: &HttpRequest) {
369    let method = req.method.to_lowercase();
370
371    // Build options hash.
372    let mut opts: Vec<String> = Vec::new();
373
374    if let Some(body) = &req.body {
375        let ruby_body = json_to_ruby(body);
376        opts.push(format!("json: {ruby_body}"));
377    }
378
379    if !req.headers.is_empty() {
380        let header_pairs: Vec<String> = req
381            .headers
382            .iter()
383            .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), ruby_string_literal(v)))
384            .collect();
385        opts.push(format!("headers: {{ {} }}", header_pairs.join(", ")));
386    }
387
388    if !req.cookies.is_empty() {
389        let cookie_str = req
390            .cookies
391            .iter()
392            .map(|(k, v)| format!("{}={}", k, v))
393            .collect::<Vec<_>>()
394            .join("; ");
395        opts.push(format!(
396            "headers: {{ 'Cookie' => {} }}",
397            ruby_string_literal(&cookie_str)
398        ));
399    }
400
401    // Build path with optional query string.
402    let path = if req.query_params.is_empty() {
403        ruby_string_literal(&req.path)
404    } else {
405        let pairs: Vec<String> = req
406            .query_params
407            .iter()
408            .map(|(k, v)| {
409                let val_str = match v {
410                    serde_json::Value::String(s) => s.clone(),
411                    other => other.to_string(),
412                };
413                format!("{}={}", k, val_str)
414            })
415            .collect();
416        ruby_string_literal(&format!("{}?{}", req.path, pairs.join("&")))
417    };
418
419    if opts.is_empty() {
420        let _ = writeln!(out, "      response = client.{method}({path})");
421    } else {
422        let _ = writeln!(out, "      response = client.{method}({path},");
423        for (i, opt) in opts.iter().enumerate() {
424            if i + 1 < opts.len() {
425                let _ = writeln!(out, "        {opt},");
426            } else {
427                let _ = writeln!(out, "        {opt}");
428            }
429        }
430        let _ = writeln!(out, "      )");
431    }
432}
433
434/// Emit body assertions for an HTTP expected response.
435fn render_ruby_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
436    if let Some(body) = &expected.body {
437        let ruby_val = json_to_ruby(body);
438        let _ = writeln!(out, "      expect(response.body).to eq({ruby_val})");
439    }
440    if let Some(partial) = &expected.body_partial {
441        if let Some(obj) = partial.as_object() {
442            for (key, val) in obj {
443                let ruby_key = ruby_string_literal(key);
444                let ruby_val = json_to_ruby(val);
445                let _ = writeln!(out, "      expect(response.body[{ruby_key}]).to eq({ruby_val})");
446            }
447        }
448    }
449    if let Some(errors) = &expected.validation_errors {
450        for err in errors {
451            let msg_lit = ruby_string_literal(&err.msg);
452            let _ = writeln!(out, "      expect(response.body.to_s).to include({msg_lit})");
453        }
454    }
455}
456
457/// Emit header assertions for an HTTP expected response.
458///
459/// Special tokens:
460/// - `"<<present>>"` — assert the header key exists
461/// - `"<<absent>>"` — assert the header key is absent
462/// - `"<<uuid>>"` — assert the header value matches a UUID regex
463fn render_ruby_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
464    for (name, value) in &expected.headers {
465        let header_key = name.to_lowercase();
466        let header_expr = format!("response.headers[{}]", ruby_string_literal(&header_key));
467        match value.as_str() {
468            "<<present>>" => {
469                let _ = writeln!(out, "      expect({header_expr}).not_to be_nil");
470            }
471            "<<absent>>" => {
472                let _ = writeln!(out, "      expect({header_expr}).to be_nil");
473            }
474            "<<uuid>>" => {
475                let _ = writeln!(
476                    out,
477                    "      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)"
478                );
479            }
480            literal => {
481                let ruby_val = ruby_string_literal(literal);
482                let _ = writeln!(out, "      expect({header_expr}).to eq({ruby_val})");
483            }
484        }
485    }
486}
487
488// ---------------------------------------------------------------------------
489// Function-call test rendering
490// ---------------------------------------------------------------------------
491
492#[allow(clippy::too_many_arguments)]
493fn render_example(
494    out: &mut String,
495    fixture: &Fixture,
496    function_name: &str,
497    call_receiver: &str,
498    result_var: &str,
499    args: &[crate::config::ArgMapping],
500    field_resolver: &FieldResolver,
501    options_type: Option<&str>,
502    enum_fields: &HashMap<String, String>,
503    result_is_simple: bool,
504) {
505    let test_name = sanitize_ident(&fixture.id);
506    let description = fixture.description.replace('\'', "\\'");
507    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
508
509    let (mut setup_lines, args_str) = build_args_and_setup(
510        &fixture.input,
511        args,
512        call_receiver,
513        options_type,
514        enum_fields,
515        result_is_simple,
516        &fixture.id,
517    );
518
519    // Build visitor if present and add to setup
520    let mut visitor_arg = String::new();
521    if let Some(visitor_spec) = &fixture.visitor {
522        visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
523    }
524
525    let final_args = if visitor_arg.is_empty() {
526        args_str
527    } else if args_str.is_empty() {
528        visitor_arg
529    } else {
530        format!("{args_str}, {visitor_arg}")
531    };
532
533    let call_expr = format!("{call_receiver}.{function_name}({final_args})");
534
535    let _ = writeln!(out, "  it '{test_name}: {description}' do");
536
537    for line in &setup_lines {
538        let _ = writeln!(out, "    {line}");
539    }
540
541    if expects_error {
542        let _ = writeln!(out, "    expect {{ {call_expr} }}.to raise_error");
543        let _ = writeln!(out, "  end");
544        return;
545    }
546
547    // Check if any non-error assertion actually uses the result variable.
548    let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
549    let _ = writeln!(out, "    {result_var} = {call_expr}");
550
551    for assertion in &fixture.assertions {
552        render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
553    }
554
555    // When all assertions were skipped (fields unavailable), the example has no
556    // expect() calls, which triggers rubocop's RSpec/NoExpectationExample cop.
557    // Emit a minimal placeholder expectation so rubocop is satisfied.
558    if !has_usable {
559        let _ = writeln!(out, "    expect({result_var}).not_to be_nil");
560    }
561
562    let _ = writeln!(out, "  end");
563}
564
565/// Build setup lines (e.g. handle creation) and the argument list for the function call.
566///
567/// Returns `(setup_lines, args_string)`.
568fn build_args_and_setup(
569    input: &serde_json::Value,
570    args: &[crate::config::ArgMapping],
571    call_receiver: &str,
572    options_type: Option<&str>,
573    enum_fields: &HashMap<String, String>,
574    result_is_simple: bool,
575    fixture_id: &str,
576) -> (Vec<String>, String) {
577    if args.is_empty() {
578        return (Vec::new(), json_to_ruby(input));
579    }
580
581    let mut setup_lines: Vec<String> = Vec::new();
582    let mut parts: Vec<String> = Vec::new();
583
584    for arg in args {
585        if arg.arg_type == "mock_url" {
586            setup_lines.push(format!(
587                "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
588                arg.name,
589            ));
590            parts.push(arg.name.clone());
591            continue;
592        }
593
594        if arg.arg_type == "handle" {
595            // Generate a create_engine (or equivalent) call and pass the variable.
596            let constructor_name = format!("create_{}", arg.name.to_snake_case());
597            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
598            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
599            if config_value.is_null()
600                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
601            {
602                setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
603            } else {
604                let literal = json_to_ruby(config_value);
605                let name = &arg.name;
606                setup_lines.push(format!("{name}_config = {literal}"));
607                setup_lines.push(format!(
608                    "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
609                    arg.name,
610                    name = name,
611                ));
612            }
613            parts.push(arg.name.clone());
614            continue;
615        }
616
617        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
618        let val = input.get(field);
619        match val {
620            None | Some(serde_json::Value::Null) if arg.optional => {
621                // Optional arg with no fixture value: skip entirely.
622                continue;
623            }
624            None | Some(serde_json::Value::Null) => {
625                // Required arg with no fixture value: pass a language-appropriate default.
626                let default_val = match arg.arg_type.as_str() {
627                    "string" => "''".to_string(),
628                    "int" | "integer" => "0".to_string(),
629                    "float" | "number" => "0.0".to_string(),
630                    "bool" | "boolean" => "false".to_string(),
631                    _ => "nil".to_string(),
632                };
633                parts.push(default_val);
634            }
635            Some(v) => {
636                // For json_object args with options_type, construct a typed options object.
637                // When result_is_simple, the binding accepts a plain Hash (no wrapper class).
638                if arg.arg_type == "json_object" && !v.is_null() {
639                    if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
640                        let kwargs: Vec<String> = obj
641                            .iter()
642                            .map(|(k, vv)| {
643                                let snake_key = k.to_snake_case();
644                                let rb_val = if enum_fields.contains_key(k) {
645                                    if let Some(s) = vv.as_str() {
646                                        let snake_val = s.to_snake_case();
647                                        format!("'{snake_val}'")
648                                    } else {
649                                        json_to_ruby(vv)
650                                    }
651                                } else {
652                                    json_to_ruby(vv)
653                                };
654                                format!("{snake_key}: {rb_val}")
655                            })
656                            .collect();
657                        if result_is_simple {
658                            parts.push(format!("{{{}}}", kwargs.join(", ")));
659                        } else {
660                            parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
661                        }
662                        continue;
663                    }
664                }
665                parts.push(json_to_ruby(v));
666            }
667        }
668    }
669
670    (setup_lines, parts.join(", "))
671}
672
673fn render_assertion(
674    out: &mut String,
675    assertion: &Assertion,
676    result_var: &str,
677    field_resolver: &FieldResolver,
678    result_is_simple: bool,
679) {
680    // Skip assertions on fields that don't exist on the result type.
681    if let Some(f) = &assertion.field {
682        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
683            let _ = writeln!(out, "    # skipped: field '{f}' not available on result type");
684            return;
685        }
686    }
687
688    // When result_is_simple, skip assertions that reference non-content fields.
689    if result_is_simple {
690        if let Some(f) = &assertion.field {
691            let f_lower = f.to_lowercase();
692            if !f.is_empty()
693                && f_lower != "content"
694                && (f_lower.starts_with("metadata")
695                    || f_lower.starts_with("document")
696                    || f_lower.starts_with("structure"))
697            {
698                return;
699            }
700        }
701    }
702
703    let field_expr = if result_is_simple {
704        result_var.to_string()
705    } else {
706        match &assertion.field {
707            Some(f) if !f.is_empty() => field_resolver.accessor(f, "ruby", result_var),
708            _ => result_var.to_string(),
709        }
710    };
711
712    // For string equality, strip trailing whitespace to handle trailing newlines
713    // from the converter.
714    let stripped_field_expr = if result_is_simple {
715        format!("{field_expr}.strip")
716    } else {
717        field_expr.clone()
718    };
719
720    match assertion.assertion_type.as_str() {
721        "equals" => {
722            if let Some(expected) = &assertion.value {
723                // Use be(true)/be(false) for booleans (RSpec/BeEq).
724                if let Some(b) = expected.as_bool() {
725                    let _ = writeln!(out, "    expect({stripped_field_expr}).to be({b})");
726                } else {
727                    let rb_val = json_to_ruby(expected);
728                    let _ = writeln!(out, "    expect({stripped_field_expr}).to eq({rb_val})");
729                }
730            }
731        }
732        "contains" => {
733            if let Some(expected) = &assertion.value {
734                let rb_val = json_to_ruby(expected);
735                // Use .to_s to handle both String and Symbol (enum) fields
736                let _ = writeln!(out, "    expect({field_expr}.to_s).to include({rb_val})");
737            }
738        }
739        "contains_all" => {
740            if let Some(values) = &assertion.values {
741                for val in values {
742                    let rb_val = json_to_ruby(val);
743                    let _ = writeln!(out, "    expect({field_expr}.to_s).to include({rb_val})");
744                }
745            }
746        }
747        "not_contains" => {
748            if let Some(expected) = &assertion.value {
749                let rb_val = json_to_ruby(expected);
750                let _ = writeln!(out, "    expect({field_expr}.to_s).not_to include({rb_val})");
751            }
752        }
753        "not_empty" => {
754            let _ = writeln!(out, "    expect({field_expr}).not_to be_empty");
755        }
756        "is_empty" => {
757            // Handle nil (None) as empty for optional fields
758            let _ = writeln!(out, "    expect({field_expr}.nil? || {field_expr}.empty?).to be(true)");
759        }
760        "contains_any" => {
761            if let Some(values) = &assertion.values {
762                let items: Vec<String> = values.iter().map(json_to_ruby).collect();
763                let arr_str = items.join(", ");
764                let _ = writeln!(
765                    out,
766                    "    expect([{arr_str}].any? {{ |v| {field_expr}.to_s.include?(v) }}).to be(true)"
767                );
768            }
769        }
770        "greater_than" => {
771            if let Some(val) = &assertion.value {
772                let rb_val = json_to_ruby(val);
773                let _ = writeln!(out, "    expect({field_expr}).to be > {rb_val}");
774            }
775        }
776        "less_than" => {
777            if let Some(val) = &assertion.value {
778                let rb_val = json_to_ruby(val);
779                let _ = writeln!(out, "    expect({field_expr}).to be < {rb_val}");
780            }
781        }
782        "greater_than_or_equal" => {
783            if let Some(val) = &assertion.value {
784                let rb_val = json_to_ruby(val);
785                let _ = writeln!(out, "    expect({field_expr}).to be >= {rb_val}");
786            }
787        }
788        "less_than_or_equal" => {
789            if let Some(val) = &assertion.value {
790                let rb_val = json_to_ruby(val);
791                let _ = writeln!(out, "    expect({field_expr}).to be <= {rb_val}");
792            }
793        }
794        "starts_with" => {
795            if let Some(expected) = &assertion.value {
796                let rb_val = json_to_ruby(expected);
797                let _ = writeln!(out, "    expect({field_expr}).to start_with({rb_val})");
798            }
799        }
800        "ends_with" => {
801            if let Some(expected) = &assertion.value {
802                let rb_val = json_to_ruby(expected);
803                let _ = writeln!(out, "    expect({field_expr}).to end_with({rb_val})");
804            }
805        }
806        "min_length" => {
807            if let Some(val) = &assertion.value {
808                if let Some(n) = val.as_u64() {
809                    let _ = writeln!(out, "    expect({field_expr}.length).to be >= {n}");
810                }
811            }
812        }
813        "max_length" => {
814            if let Some(val) = &assertion.value {
815                if let Some(n) = val.as_u64() {
816                    let _ = writeln!(out, "    expect({field_expr}.length).to be <= {n}");
817                }
818            }
819        }
820        "count_min" => {
821            if let Some(val) = &assertion.value {
822                if let Some(n) = val.as_u64() {
823                    let _ = writeln!(out, "    expect({field_expr}.length).to be >= {n}");
824                }
825            }
826        }
827        "count_equals" => {
828            if let Some(val) = &assertion.value {
829                if let Some(n) = val.as_u64() {
830                    let _ = writeln!(out, "    expect({field_expr}.length).to eq({n})");
831                }
832            }
833        }
834        "is_true" => {
835            let _ = writeln!(out, "    expect({field_expr}).to be true");
836        }
837        "not_error" => {
838            // Already handled by the call succeeding without exception.
839        }
840        "error" => {
841            // Handled at the example level.
842        }
843        other => {
844            let _ = writeln!(out, "    # TODO: unsupported assertion type: {other}");
845        }
846    }
847}
848
849/// Convert a module path (e.g., "html_to_markdown") to Ruby PascalCase module name
850/// (e.g., "HtmlToMarkdown").
851fn ruby_module_name(module_path: &str) -> String {
852    use heck::ToUpperCamelCase;
853    module_path.to_upper_camel_case()
854}
855
856/// Convert a `serde_json::Value` to a Ruby literal string, preferring single quotes.
857fn json_to_ruby(value: &serde_json::Value) -> String {
858    match value {
859        serde_json::Value::String(s) => ruby_string_literal(s),
860        serde_json::Value::Bool(true) => "true".to_string(),
861        serde_json::Value::Bool(false) => "false".to_string(),
862        serde_json::Value::Number(n) => n.to_string(),
863        serde_json::Value::Null => "nil".to_string(),
864        serde_json::Value::Array(arr) => {
865            let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
866            format!("[{}]", items.join(", "))
867        }
868        serde_json::Value::Object(map) => {
869            let items: Vec<String> = map
870                .iter()
871                .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
872                .collect();
873            format!("{{ {} }}", items.join(", "))
874        }
875    }
876}
877
878// ---------------------------------------------------------------------------
879// Visitor generation
880// ---------------------------------------------------------------------------
881
882/// Build a Ruby visitor object and add setup lines. Returns the visitor expression.
883fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
884    setup_lines.push("visitor = Class.new do".to_string());
885    for (method_name, action) in &visitor_spec.callbacks {
886        emit_ruby_visitor_method(setup_lines, method_name, action);
887    }
888    setup_lines.push("end.new".to_string());
889    "visitor".to_string()
890}
891
892/// Emit a Ruby visitor method for a callback action.
893fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
894    let snake_method = method_name;
895    let params = match method_name {
896        "visit_link" => "ctx, href, text, title",
897        "visit_image" => "ctx, src, alt, title",
898        "visit_heading" => "ctx, level, text, id",
899        "visit_code_block" => "ctx, lang, code",
900        "visit_code_inline"
901        | "visit_strong"
902        | "visit_emphasis"
903        | "visit_strikethrough"
904        | "visit_underline"
905        | "visit_subscript"
906        | "visit_superscript"
907        | "visit_mark"
908        | "visit_button"
909        | "visit_summary"
910        | "visit_figcaption"
911        | "visit_definition_term"
912        | "visit_definition_description" => "ctx, text",
913        "visit_text" => "ctx, text",
914        "visit_list_item" => "ctx, ordered, marker, text",
915        "visit_blockquote" => "ctx, content, depth",
916        "visit_table_row" => "ctx, cells, is_header",
917        "visit_custom_element" => "ctx, tag_name, html",
918        "visit_form" => "ctx, action_url, method",
919        "visit_input" => "ctx, input_type, name, value",
920        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
921        "visit_details" => "ctx, is_open",
922        _ => "ctx",
923    };
924
925    setup_lines.push(format!("  def {snake_method}({params})"));
926    match action {
927        CallbackAction::Skip => {
928            setup_lines.push("    'skip'".to_string());
929        }
930        CallbackAction::Continue => {
931            setup_lines.push("    'continue'".to_string());
932        }
933        CallbackAction::PreserveHtml => {
934            setup_lines.push("    'preserve_html'".to_string());
935        }
936        CallbackAction::Custom { output } => {
937            let escaped = ruby_string_literal(output);
938            setup_lines.push(format!("    {{ custom: {escaped} }}"));
939        }
940        CallbackAction::CustomTemplate { template } => {
941            setup_lines.push(format!("    {{ custom: \"{template}\" }}"));
942        }
943    }
944    setup_lines.push("  end".to_string());
945}