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::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use heck::ToSnakeCase;
14use std::collections::HashMap;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20/// Ruby e2e code generator.
21pub struct RubyCodegen;
22
23impl E2eCodegen for RubyCodegen {
24    fn generate(
25        &self,
26        groups: &[FixtureGroup],
27        e2e_config: &E2eConfig,
28        alef_config: &AlefConfig,
29    ) -> Result<Vec<GeneratedFile>> {
30        let lang = self.language_name();
31        let output_base = PathBuf::from(&e2e_config.output).join(lang);
32
33        let mut files = Vec::new();
34
35        // Resolve call config with overrides.
36        let call = &e2e_config.call;
37        let overrides = call.overrides.get(lang);
38        let module_path = overrides
39            .and_then(|o| o.module.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.module.clone());
42        let function_name = overrides
43            .and_then(|o| o.function.as_ref())
44            .cloned()
45            .unwrap_or_else(|| call.function.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        let result_var = &call.result_var;
52
53        // Resolve package config.
54        let ruby_pkg = e2e_config.packages.get("ruby");
55        let gem_name = ruby_pkg
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            .and_then(|p| p.path.as_ref())
61            .cloned()
62            .unwrap_or_else(|| "../../packages/ruby".to_string());
63
64        // Generate Gemfile.
65        files.push(GeneratedFile {
66            path: output_base.join("Gemfile"),
67            content: render_gemfile(&gem_name, &gem_path),
68            generated_header: false,
69        });
70
71        // Generate .rubocop.yaml for linting generated specs.
72        files.push(GeneratedFile {
73            path: output_base.join(".rubocop.yaml"),
74            content: render_rubocop_yaml(),
75            generated_header: false,
76        });
77
78        // Generate spec files per category.
79        let spec_base = output_base.join("spec");
80
81        for group in groups {
82            let active: Vec<&Fixture> = group
83                .fixtures
84                .iter()
85                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
86                .collect();
87
88            if active.is_empty() {
89                continue;
90            }
91
92            let field_resolver_pre = FieldResolver::new(
93                &e2e_config.fields,
94                &e2e_config.fields_optional,
95                &e2e_config.result_fields,
96                &e2e_config.fields_array,
97            );
98            // Skip the entire file if no fixture in this category produces output.
99            let has_any_output = active.iter().any(|f| {
100                let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
101                expects_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
102            });
103            if !has_any_output {
104                continue;
105            }
106
107            let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
108            let field_resolver = FieldResolver::new(
109                &e2e_config.fields,
110                &e2e_config.fields_optional,
111                &e2e_config.result_fields,
112                &e2e_config.fields_array,
113            );
114            let content = render_spec_file(
115                &group.category,
116                &active,
117                &module_path,
118                &function_name,
119                class_name.as_deref(),
120                result_var,
121                &gem_name,
122                &e2e_config.call.args,
123                &field_resolver,
124                options_type.as_deref(),
125                enum_fields,
126                result_is_simple,
127            );
128            files.push(GeneratedFile {
129                path: spec_base.join(filename),
130                content,
131                generated_header: true,
132            });
133        }
134
135        Ok(files)
136    }
137
138    fn language_name(&self) -> &'static str {
139        "ruby"
140    }
141}
142
143// ---------------------------------------------------------------------------
144// Rendering
145// ---------------------------------------------------------------------------
146
147fn render_gemfile(gem_name: &str, gem_path: &str) -> String {
148    format!(
149        "# frozen_string_literal: true\n\
150         \n\
151         source 'https://rubygems.org'\n\
152         \n\
153         gem '{gem_name}', path: '{gem_path}'\n\
154         gem 'rspec', '~> 3.13'\n\
155         gem 'rubocop', '~> 1.86'\n\
156         gem 'rubocop-rspec', '~> 3.9'\n"
157    )
158}
159
160fn render_rubocop_yaml() -> String {
161    r#"# Generated by alef e2e — do not edit.
162AllCops:
163  NewCops: enable
164  TargetRubyVersion: 3.2
165  SuggestExtensions: false
166
167plugins:
168  - rubocop-rspec
169
170# --- Justified suppressions for generated test code ---
171
172# Generated tests are verbose by nature (setup + multiple assertions).
173Metrics/BlockLength:
174  Enabled: false
175Metrics/MethodLength:
176  Enabled: false
177Layout/LineLength:
178  Enabled: false
179
180# Generated tests use multiple assertions per example for thorough verification.
181RSpec/MultipleExpectations:
182  Enabled: false
183RSpec/ExampleLength:
184  Enabled: false
185
186# Generated tests describe categories as strings, not classes.
187RSpec/DescribeClass:
188  Enabled: false
189
190# Fixture-driven tests may produce identical assertion bodies for different inputs.
191RSpec/RepeatedExample:
192  Enabled: false
193
194# Error-handling tests use bare raise_error (exception type not known at generation time).
195RSpec/UnspecifiedException:
196  Enabled: false
197"#
198    .to_string()
199}
200
201#[allow(clippy::too_many_arguments)]
202fn render_spec_file(
203    category: &str,
204    fixtures: &[&Fixture],
205    module_path: &str,
206    function_name: &str,
207    class_name: Option<&str>,
208    result_var: &str,
209    gem_name: &str,
210    args: &[crate::config::ArgMapping],
211    field_resolver: &FieldResolver,
212    options_type: Option<&str>,
213    enum_fields: &HashMap<String, String>,
214    result_is_simple: bool,
215) -> String {
216    let mut out = String::new();
217    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
218    let _ = writeln!(out, "# frozen_string_literal: true");
219    let _ = writeln!(out);
220
221    // Require the gem (single quotes).
222    let require_name = if module_path.is_empty() { gem_name } else { module_path };
223    let _ = writeln!(out, "require '{}'", require_name.replace('-', "_"));
224    let _ = writeln!(out, "require 'json'");
225    let _ = writeln!(out);
226
227    // Build the Ruby module/class qualifier for calls.
228    let call_receiver = class_name
229        .map(|s| s.to_string())
230        .unwrap_or_else(|| ruby_module_name(module_path));
231
232    let _ = writeln!(out, "RSpec.describe '{}' do", category);
233
234    let mut first = true;
235    for fixture in fixtures {
236        // Skip examples that have zero usable assertions (no executable expect() calls).
237        // This prevents Lint/UselessAssignment, RSpec/NoExpectationExample,
238        // and RSpec/RepeatedExample.
239        let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
240        if !expects_error && !has_usable_assertion(fixture, field_resolver, result_is_simple) {
241            continue;
242        }
243
244        if !first {
245            let _ = writeln!(out);
246        }
247        first = false;
248
249        render_example(
250            &mut out,
251            fixture,
252            function_name,
253            &call_receiver,
254            result_var,
255            args,
256            field_resolver,
257            options_type,
258            enum_fields,
259            result_is_simple,
260        );
261    }
262
263    let _ = writeln!(out, "end");
264    out
265}
266
267/// Check if a fixture has at least one assertion that will produce an executable
268/// expect() call (not just a skip comment).
269fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
270    fixture.assertions.iter().any(|a| {
271        // not_error is implicit (call succeeding), error is handled separately.
272        if a.assertion_type == "not_error" || a.assertion_type == "error" {
273            return false;
274        }
275        // Check field validity.
276        if let Some(f) = &a.field {
277            if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
278                return false;
279            }
280            // When result_is_simple, skip non-content fields.
281            if result_is_simple {
282                let f_lower = f.to_lowercase();
283                if !f.is_empty()
284                    && f_lower != "content"
285                    && (f_lower.starts_with("metadata")
286                        || f_lower.starts_with("document")
287                        || f_lower.starts_with("structure"))
288                {
289                    return false;
290                }
291            }
292        }
293        true
294    })
295}
296
297#[allow(clippy::too_many_arguments)]
298fn render_example(
299    out: &mut String,
300    fixture: &Fixture,
301    function_name: &str,
302    call_receiver: &str,
303    result_var: &str,
304    args: &[crate::config::ArgMapping],
305    field_resolver: &FieldResolver,
306    options_type: Option<&str>,
307    enum_fields: &HashMap<String, String>,
308    result_is_simple: bool,
309) {
310    let test_name = sanitize_ident(&fixture.id);
311    let description = fixture.description.replace('\'', "\\'");
312    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
313
314    let (setup_lines, args_str) = build_args_and_setup(
315        &fixture.input,
316        args,
317        call_receiver,
318        options_type,
319        enum_fields,
320        result_is_simple,
321        &fixture.id,
322    );
323
324    let call_expr = format!("{call_receiver}.{function_name}({args_str})");
325
326    let _ = writeln!(out, "  it '{test_name}: {description}' do");
327
328    for line in &setup_lines {
329        let _ = writeln!(out, "    {line}");
330    }
331
332    if expects_error {
333        let _ = writeln!(out, "    expect {{ {call_expr} }}.to raise_error");
334        let _ = writeln!(out, "  end");
335        return;
336    }
337
338    // Check if any non-error assertion actually uses the result variable.
339    let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
340    if has_usable {
341        let _ = writeln!(out, "    {result_var} = {call_expr}");
342    } else {
343        let _ = writeln!(out, "    {call_expr}");
344    }
345
346    for assertion in &fixture.assertions {
347        render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
348    }
349
350    let _ = writeln!(out, "  end");
351}
352
353/// Build setup lines (e.g. handle creation) and the argument list for the function call.
354///
355/// Returns `(setup_lines, args_string)`.
356fn build_args_and_setup(
357    input: &serde_json::Value,
358    args: &[crate::config::ArgMapping],
359    call_receiver: &str,
360    options_type: Option<&str>,
361    enum_fields: &HashMap<String, String>,
362    result_is_simple: bool,
363    fixture_id: &str,
364) -> (Vec<String>, String) {
365    if args.is_empty() {
366        return (Vec::new(), json_to_ruby(input));
367    }
368
369    let mut setup_lines: Vec<String> = Vec::new();
370    let mut parts: Vec<String> = Vec::new();
371
372    for arg in args {
373        if arg.arg_type == "mock_url" {
374            setup_lines.push(format!(
375                "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
376                arg.name,
377            ));
378            parts.push(arg.name.clone());
379            continue;
380        }
381
382        if arg.arg_type == "handle" {
383            // Generate a create_engine (or equivalent) call and pass the variable.
384            let constructor_name = format!("create_{}", arg.name.to_snake_case());
385            let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
386            if config_value.is_null()
387                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
388            {
389                setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
390            } else {
391                let literal = json_to_ruby(config_value);
392                let name = &arg.name;
393                setup_lines.push(format!("{name}_config = {literal}"));
394                setup_lines.push(format!(
395                    "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
396                    arg.name,
397                    name = name,
398                ));
399            }
400            parts.push(arg.name.clone());
401            continue;
402        }
403
404        let val = input.get(&arg.field);
405        match val {
406            None | Some(serde_json::Value::Null) if arg.optional => {
407                // Optional arg with no fixture value: skip entirely.
408                continue;
409            }
410            None | Some(serde_json::Value::Null) => {
411                // Required arg with no fixture value: pass a language-appropriate default.
412                let default_val = match arg.arg_type.as_str() {
413                    "string" => "''".to_string(),
414                    "int" | "integer" => "0".to_string(),
415                    "float" | "number" => "0.0".to_string(),
416                    "bool" | "boolean" => "false".to_string(),
417                    _ => "nil".to_string(),
418                };
419                parts.push(default_val);
420            }
421            Some(v) => {
422                // For json_object args with options_type, construct a typed options object.
423                // When result_is_simple, the binding accepts a plain Hash (no wrapper class).
424                if arg.arg_type == "json_object" && !v.is_null() {
425                    if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
426                        let kwargs: Vec<String> = obj
427                            .iter()
428                            .map(|(k, vv)| {
429                                let snake_key = k.to_snake_case();
430                                let rb_val = if enum_fields.contains_key(k) {
431                                    if let Some(s) = vv.as_str() {
432                                        let snake_val = s.to_snake_case();
433                                        format!("'{snake_val}'")
434                                    } else {
435                                        json_to_ruby(vv)
436                                    }
437                                } else {
438                                    json_to_ruby(vv)
439                                };
440                                format!("{snake_key}: {rb_val}")
441                            })
442                            .collect();
443                        if result_is_simple {
444                            parts.push(format!("{{{}}}", kwargs.join(", ")));
445                        } else {
446                            parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
447                        }
448                        continue;
449                    }
450                }
451                parts.push(json_to_ruby(v));
452            }
453        }
454    }
455
456    (setup_lines, parts.join(", "))
457}
458
459fn render_assertion(
460    out: &mut String,
461    assertion: &Assertion,
462    result_var: &str,
463    field_resolver: &FieldResolver,
464    result_is_simple: bool,
465) {
466    // Skip assertions on fields that don't exist on the result type.
467    if let Some(f) = &assertion.field {
468        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
469            // Don't emit skip comments — the example-level filter ensures we only
470            // get here in mixed cases, and the comment would be noise.
471            return;
472        }
473    }
474
475    // When result_is_simple, skip assertions that reference non-content fields.
476    if result_is_simple {
477        if let Some(f) = &assertion.field {
478            let f_lower = f.to_lowercase();
479            if !f.is_empty()
480                && f_lower != "content"
481                && (f_lower.starts_with("metadata")
482                    || f_lower.starts_with("document")
483                    || f_lower.starts_with("structure"))
484            {
485                return;
486            }
487        }
488    }
489
490    let field_expr = if result_is_simple {
491        result_var.to_string()
492    } else {
493        match &assertion.field {
494            Some(f) if !f.is_empty() => field_resolver.accessor(f, "ruby", result_var),
495            _ => result_var.to_string(),
496        }
497    };
498
499    // For string equality, strip trailing whitespace to handle trailing newlines
500    // from the converter.
501    let stripped_field_expr = if result_is_simple {
502        format!("{field_expr}.strip")
503    } else {
504        field_expr.clone()
505    };
506
507    match assertion.assertion_type.as_str() {
508        "equals" => {
509            if let Some(expected) = &assertion.value {
510                // Use be(true)/be(false) for booleans (RSpec/BeEq).
511                if let Some(b) = expected.as_bool() {
512                    let _ = writeln!(out, "    expect({stripped_field_expr}).to be({b})");
513                } else {
514                    let rb_val = json_to_ruby(expected);
515                    let _ = writeln!(out, "    expect({stripped_field_expr}).to eq({rb_val})");
516                }
517            }
518        }
519        "contains" => {
520            if let Some(expected) = &assertion.value {
521                let rb_val = json_to_ruby(expected);
522                // Use .to_s to handle both String and Symbol (enum) fields
523                let _ = writeln!(out, "    expect({field_expr}.to_s).to include({rb_val})");
524            }
525        }
526        "contains_all" => {
527            if let Some(values) = &assertion.values {
528                for val in values {
529                    let rb_val = json_to_ruby(val);
530                    let _ = writeln!(out, "    expect({field_expr}.to_s).to include({rb_val})");
531                }
532            }
533        }
534        "not_contains" => {
535            if let Some(expected) = &assertion.value {
536                let rb_val = json_to_ruby(expected);
537                let _ = writeln!(out, "    expect({field_expr}.to_s).not_to include({rb_val})");
538            }
539        }
540        "not_empty" => {
541            let _ = writeln!(out, "    expect({field_expr}).not_to be_empty");
542        }
543        "is_empty" => {
544            // Handle nil (None) as empty for optional fields
545            let _ = writeln!(out, "    expect({field_expr}.nil? || {field_expr}.empty?).to be(true)");
546        }
547        "contains_any" => {
548            if let Some(values) = &assertion.values {
549                let items: Vec<String> = values.iter().map(json_to_ruby).collect();
550                let arr_str = items.join(", ");
551                let _ = writeln!(
552                    out,
553                    "    expect([{arr_str}].any? {{ |v| {field_expr}.to_s.include?(v) }}).to be(true)"
554                );
555            }
556        }
557        "greater_than" => {
558            if let Some(val) = &assertion.value {
559                let rb_val = json_to_ruby(val);
560                let _ = writeln!(out, "    expect({field_expr}).to be > {rb_val}");
561            }
562        }
563        "less_than" => {
564            if let Some(val) = &assertion.value {
565                let rb_val = json_to_ruby(val);
566                let _ = writeln!(out, "    expect({field_expr}).to be < {rb_val}");
567            }
568        }
569        "greater_than_or_equal" => {
570            if let Some(val) = &assertion.value {
571                let rb_val = json_to_ruby(val);
572                let _ = writeln!(out, "    expect({field_expr}).to be >= {rb_val}");
573            }
574        }
575        "less_than_or_equal" => {
576            if let Some(val) = &assertion.value {
577                let rb_val = json_to_ruby(val);
578                let _ = writeln!(out, "    expect({field_expr}).to be <= {rb_val}");
579            }
580        }
581        "starts_with" => {
582            if let Some(expected) = &assertion.value {
583                let rb_val = json_to_ruby(expected);
584                let _ = writeln!(out, "    expect({field_expr}).to start_with({rb_val})");
585            }
586        }
587        "ends_with" => {
588            if let Some(expected) = &assertion.value {
589                let rb_val = json_to_ruby(expected);
590                let _ = writeln!(out, "    expect({field_expr}).to end_with({rb_val})");
591            }
592        }
593        "min_length" => {
594            if let Some(val) = &assertion.value {
595                if let Some(n) = val.as_u64() {
596                    let _ = writeln!(out, "    expect({field_expr}.length).to be >= {n}");
597                }
598            }
599        }
600        "max_length" => {
601            if let Some(val) = &assertion.value {
602                if let Some(n) = val.as_u64() {
603                    let _ = writeln!(out, "    expect({field_expr}.length).to be <= {n}");
604                }
605            }
606        }
607        "count_min" => {
608            if let Some(val) = &assertion.value {
609                if let Some(n) = val.as_u64() {
610                    let _ = writeln!(out, "    expect({field_expr}.length).to be >= {n}");
611                }
612            }
613        }
614        "not_error" => {
615            // Already handled by the call succeeding without exception.
616        }
617        "error" => {
618            // Handled at the example level.
619        }
620        other => {
621            let _ = writeln!(out, "    # TODO: unsupported assertion type: {other}");
622        }
623    }
624}
625
626/// Convert a module path (e.g., "html_to_markdown") to Ruby PascalCase module name
627/// (e.g., "HtmlToMarkdown").
628fn ruby_module_name(module_path: &str) -> String {
629    use heck::ToUpperCamelCase;
630    module_path.to_upper_camel_case()
631}
632
633/// Convert a `serde_json::Value` to a Ruby literal string, preferring single quotes.
634fn json_to_ruby(value: &serde_json::Value) -> String {
635    match value {
636        serde_json::Value::String(s) => ruby_string_literal(s),
637        serde_json::Value::Bool(true) => "true".to_string(),
638        serde_json::Value::Bool(false) => "false".to_string(),
639        serde_json::Value::Number(n) => n.to_string(),
640        serde_json::Value::Null => "nil".to_string(),
641        serde_json::Value::Array(arr) => {
642            let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
643            format!("[{}]", items.join(", "))
644        }
645        serde_json::Value::Object(map) => {
646            let items: Vec<String> = map
647                .iter()
648                .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
649                .collect();
650            format!("{{ {} }}", items.join(", "))
651        }
652    }
653}