1use crate::config::E2eConfig;
7use crate::escape::{escape_ruby, 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 std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16use super::E2eCodegen;
17
18pub struct RubyCodegen;
20
21impl E2eCodegen for RubyCodegen {
22 fn generate(
23 &self,
24 groups: &[FixtureGroup],
25 e2e_config: &E2eConfig,
26 alef_config: &AlefConfig,
27 ) -> Result<Vec<GeneratedFile>> {
28 let lang = self.language_name();
29 let output_base = PathBuf::from(&e2e_config.output).join(lang);
30
31 let mut files = Vec::new();
32
33 let call = &e2e_config.call;
35 let overrides = call.overrides.get(lang);
36 let module_path = overrides
37 .and_then(|o| o.module.as_ref())
38 .cloned()
39 .unwrap_or_else(|| call.module.clone());
40 let function_name = overrides
41 .and_then(|o| o.function.as_ref())
42 .cloned()
43 .unwrap_or_else(|| call.function.clone());
44 let class_name = overrides.and_then(|o| o.class.as_ref()).cloned();
45 let result_var = &call.result_var;
46
47 let ruby_pkg = e2e_config.packages.get("ruby");
49 let gem_name = ruby_pkg
50 .and_then(|p| p.name.as_ref())
51 .cloned()
52 .unwrap_or_else(|| alef_config.crate_config.name.replace('-', "_"));
53 let gem_path = ruby_pkg
54 .and_then(|p| p.path.as_ref())
55 .cloned()
56 .unwrap_or_else(|| "../../packages/ruby".to_string());
57
58 files.push(GeneratedFile {
60 path: output_base.join("Gemfile"),
61 content: render_gemfile(&gem_name, &gem_path),
62 generated_header: false,
63 });
64
65 let spec_base = output_base.join("spec");
67
68 for group in groups {
69 let active: Vec<&Fixture> = group
70 .fixtures
71 .iter()
72 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
73 .collect();
74
75 if active.is_empty() {
76 continue;
77 }
78
79 let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
80 let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
81 let content = render_spec_file(
82 &group.category,
83 &active,
84 &module_path,
85 &function_name,
86 class_name.as_deref(),
87 result_var,
88 &gem_name,
89 &e2e_config.call.args,
90 &field_resolver,
91 );
92 files.push(GeneratedFile {
93 path: spec_base.join(filename),
94 content,
95 generated_header: true,
96 });
97 }
98
99 Ok(files)
100 }
101
102 fn language_name(&self) -> &'static str {
103 "ruby"
104 }
105}
106
107fn render_gemfile(gem_name: &str, gem_path: &str) -> String {
112 format!(
113 r#"# frozen_string_literal: true
114
115source "https://rubygems.org"
116
117gem "{gem_name}", path: "{gem_path}"
118gem "rspec", "~> 3.13"
119"#
120 )
121}
122
123#[allow(clippy::too_many_arguments)]
124fn render_spec_file(
125 category: &str,
126 fixtures: &[&Fixture],
127 module_path: &str,
128 function_name: &str,
129 class_name: Option<&str>,
130 result_var: &str,
131 gem_name: &str,
132 args: &[crate::config::ArgMapping],
133 field_resolver: &FieldResolver,
134) -> String {
135 let mut out = String::new();
136 let _ = writeln!(out, "# frozen_string_literal: true");
137 let _ = writeln!(out);
138
139 let require_name = if module_path.is_empty() { gem_name } else { module_path };
141 let _ = writeln!(out, "require \"{}\"", require_name.replace('-', "_"));
142 let _ = writeln!(out);
143
144 let _ = writeln!(out, "RSpec.describe \"{category}\" do");
145
146 for (i, fixture) in fixtures.iter().enumerate() {
147 render_example(
148 &mut out,
149 fixture,
150 function_name,
151 class_name,
152 result_var,
153 args,
154 field_resolver,
155 );
156 if i + 1 < fixtures.len() {
157 let _ = writeln!(out);
158 }
159 }
160
161 let _ = writeln!(out, "end");
162 out
163}
164
165fn render_example(
166 out: &mut String,
167 fixture: &Fixture,
168 function_name: &str,
169 class_name: Option<&str>,
170 result_var: &str,
171 args: &[crate::config::ArgMapping],
172 field_resolver: &FieldResolver,
173) {
174 let test_name = sanitize_ident(&fixture.id);
175 let description = fixture.description.replace('"', "\\\"");
176 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
177
178 let args_str = build_args_string(&fixture.input, args);
179
180 let call_expr = match class_name {
181 Some(cls) => format!("{cls}.{function_name}({args_str})"),
182 None => format!("{function_name}({args_str})"),
183 };
184
185 let _ = writeln!(out, " it \"{test_name}: {description}\" do");
186
187 if expects_error {
188 let _ = writeln!(out, " expect {{ {call_expr} }}.to raise_error");
189 let _ = writeln!(out, " end");
190 return;
191 }
192
193 let _ = writeln!(out, " {result_var} = {call_expr}");
194
195 for assertion in &fixture.assertions {
196 render_assertion(out, assertion, result_var, field_resolver);
197 }
198
199 let _ = writeln!(out, " end");
200}
201
202fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
203 if args.is_empty() {
204 return json_to_ruby(input);
205 }
206
207 let parts: Vec<String> = args
208 .iter()
209 .filter_map(|arg| {
210 let val = input.get(&arg.field)?;
211 if val.is_null() && arg.optional {
212 return None;
213 }
214 Some(json_to_ruby(val))
215 })
216 .collect();
217
218 parts.join(", ")
219}
220
221fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
222 let field_expr = match &assertion.field {
223 Some(f) if !f.is_empty() => field_resolver.accessor(f, "ruby", result_var),
224 _ => result_var.to_string(),
225 };
226
227 match assertion.assertion_type.as_str() {
228 "equals" => {
229 if let Some(expected) = &assertion.value {
230 let rb_val = json_to_ruby(expected);
231 let _ = writeln!(out, " expect({field_expr}.strip).to eq({rb_val})");
232 }
233 }
234 "contains" => {
235 if let Some(expected) = &assertion.value {
236 let rb_val = json_to_ruby(expected);
237 let _ = writeln!(out, " expect({field_expr}).to include({rb_val})");
238 }
239 }
240 "contains_all" => {
241 if let Some(values) = &assertion.values {
242 for val in values {
243 let rb_val = json_to_ruby(val);
244 let _ = writeln!(out, " expect({field_expr}).to include({rb_val})");
245 }
246 }
247 }
248 "not_contains" => {
249 if let Some(expected) = &assertion.value {
250 let rb_val = json_to_ruby(expected);
251 let _ = writeln!(out, " expect({field_expr}).not_to include({rb_val})");
252 }
253 }
254 "not_empty" => {
255 let _ = writeln!(out, " expect({field_expr}).not_to be_empty");
256 }
257 "is_empty" => {
258 let _ = writeln!(out, " expect({field_expr}).to be_empty");
259 }
260 "starts_with" => {
261 if let Some(expected) = &assertion.value {
262 let rb_val = json_to_ruby(expected);
263 let _ = writeln!(out, " expect({field_expr}).to start_with({rb_val})");
264 }
265 }
266 "ends_with" => {
267 if let Some(expected) = &assertion.value {
268 let rb_val = json_to_ruby(expected);
269 let _ = writeln!(out, " expect({field_expr}).to end_with({rb_val})");
270 }
271 }
272 "min_length" => {
273 if let Some(val) = &assertion.value {
274 if let Some(n) = val.as_u64() {
275 let _ = writeln!(out, " expect({field_expr}.length).to be >= {n}");
276 }
277 }
278 }
279 "max_length" => {
280 if let Some(val) = &assertion.value {
281 if let Some(n) = val.as_u64() {
282 let _ = writeln!(out, " expect({field_expr}.length).to be <= {n}");
283 }
284 }
285 }
286 "not_error" => {
287 }
289 "error" => {
290 }
292 other => {
293 let _ = writeln!(out, " # TODO: unsupported assertion type: {other}");
294 }
295 }
296}
297
298fn json_to_ruby(value: &serde_json::Value) -> String {
300 match value {
301 serde_json::Value::String(s) => format!("\"{}\"", escape_ruby(s)),
302 serde_json::Value::Bool(true) => "true".to_string(),
303 serde_json::Value::Bool(false) => "false".to_string(),
304 serde_json::Value::Number(n) => n.to_string(),
305 serde_json::Value::Null => "nil".to_string(),
306 serde_json::Value::Array(arr) => {
307 let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
308 format!("[{}]", items.join(", "))
309 }
310 serde_json::Value::Object(map) => {
311 let items: Vec<String> = map
312 .iter()
313 .map(|(k, v)| format!("\"{}\" => {}", escape_ruby(k), json_to_ruby(v)))
314 .collect();
315 format!("{{ {} }}", items.join(", "))
316 }
317 }
318}