1use 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
20pub 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 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 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 files.push(GeneratedFile {
66 path: output_base.join("Gemfile"),
67 content: render_gemfile(&gem_name, &gem_path),
68 generated_header: false,
69 });
70
71 files.push(GeneratedFile {
73 path: output_base.join(".rubocop.yaml"),
74 content: render_rubocop_yaml(),
75 generated_header: false,
76 });
77
78 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 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
143fn 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 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 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 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
267fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
270 fixture.assertions.iter().any(|a| {
271 if a.assertion_type == "not_error" || a.assertion_type == "error" {
273 return false;
274 }
275 if let Some(f) = &a.field {
277 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
278 return false;
279 }
280 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 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
353fn 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 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 continue;
409 }
410 None | Some(serde_json::Value::Null) => {
411 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 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 if let Some(f) = &assertion.field {
468 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
469 return;
472 }
473 }
474
475 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 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 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 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 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 }
617 "error" => {
618 }
620 other => {
621 let _ = writeln!(out, " # TODO: unsupported assertion type: {other}");
622 }
623 }
624}
625
626fn ruby_module_name(module_path: &str) -> String {
629 use heck::ToUpperCamelCase;
630 module_path.to_upper_camel_case()
631}
632
633fn 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}