1use crate::config::E2eConfig;
4use crate::escape::{escape_r, sanitize_filename, sanitize_ident};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, Fixture, FixtureGroup};
7use alef_core::backend::GeneratedFile;
8use alef_core::config::AlefConfig;
9use anyhow::Result;
10use std::fmt::Write as FmtWrite;
11use std::path::PathBuf;
12
13use super::E2eCodegen;
14
15pub struct RCodegen;
17
18impl E2eCodegen for RCodegen {
19 fn generate(
20 &self,
21 groups: &[FixtureGroup],
22 e2e_config: &E2eConfig,
23 _alef_config: &AlefConfig,
24 ) -> Result<Vec<GeneratedFile>> {
25 let lang = self.language_name();
26 let output_base = PathBuf::from(&e2e_config.output).join(lang);
27
28 let mut files = Vec::new();
29
30 let call = &e2e_config.call;
32 let overrides = call.overrides.get(lang);
33 let module_path = overrides
34 .and_then(|o| o.module.as_ref())
35 .cloned()
36 .unwrap_or_else(|| call.module.clone());
37 let function_name = overrides
38 .and_then(|o| o.function.as_ref())
39 .cloned()
40 .unwrap_or_else(|| call.function.clone());
41 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
42 let result_var = &call.result_var;
43
44 let r_pkg = e2e_config.packages.get("r");
46 let pkg_name = r_pkg
47 .and_then(|p| p.name.as_ref())
48 .cloned()
49 .unwrap_or_else(|| module_path.clone());
50 let pkg_path = r_pkg
51 .and_then(|p| p.path.as_ref())
52 .cloned()
53 .unwrap_or_else(|| "../../packages/r".to_string());
54
55 files.push(GeneratedFile {
57 path: output_base.join("DESCRIPTION"),
58 content: render_description(&pkg_name),
59 generated_header: false,
60 });
61
62 files.push(GeneratedFile {
64 path: output_base.join("run_tests.R"),
65 content: render_test_runner(&pkg_path),
66 generated_header: true,
67 });
68
69 for group in groups {
71 let active: Vec<&Fixture> = group
72 .fixtures
73 .iter()
74 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
75 .collect();
76
77 if active.is_empty() {
78 continue;
79 }
80
81 let filename = format!("test_{}.R", sanitize_filename(&group.category));
82 let field_resolver = FieldResolver::new(
83 &e2e_config.fields,
84 &e2e_config.fields_optional,
85 &e2e_config.result_fields,
86 &e2e_config.fields_array,
87 );
88 let content = render_test_file(
89 &group.category,
90 &active,
91 &function_name,
92 result_var,
93 &e2e_config.call.args,
94 &field_resolver,
95 result_is_simple,
96 );
97 files.push(GeneratedFile {
98 path: output_base.join("tests").join(filename),
99 content,
100 generated_header: true,
101 });
102 }
103
104 Ok(files)
105 }
106
107 fn language_name(&self) -> &'static str {
108 "r"
109 }
110}
111
112fn render_description(pkg_name: &str) -> String {
113 format!(
114 r#"Package: e2e.r
115Title: E2E Tests for {pkg_name}
116Version: 0.1.0
117Description: End-to-end test suite.
118Suggests: testthat (>= 3.0.0)
119Config/testthat/edition: 3
120"#
121 )
122}
123
124fn render_test_runner(pkg_path: &str) -> String {
125 let mut out = String::new();
126 let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
127 let _ = writeln!(out, "library(testthat)");
128 let _ = writeln!(out, "devtools::load_all(\"{pkg_path}\")");
131 let _ = writeln!(out);
132 let _ = writeln!(out, "test_dir(\"tests\")");
133 out
134}
135
136fn render_test_file(
137 category: &str,
138 fixtures: &[&Fixture],
139 function_name: &str,
140 result_var: &str,
141 args: &[crate::config::ArgMapping],
142 field_resolver: &FieldResolver,
143 result_is_simple: bool,
144) -> String {
145 let mut out = String::new();
146 let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
147 let _ = writeln!(out, "# E2e tests for category: {category}");
148 let _ = writeln!(out);
149
150 for (i, fixture) in fixtures.iter().enumerate() {
151 render_test_case(
152 &mut out,
153 fixture,
154 function_name,
155 result_var,
156 args,
157 field_resolver,
158 result_is_simple,
159 );
160 if i + 1 < fixtures.len() {
161 let _ = writeln!(out);
162 }
163 }
164
165 while out.ends_with("\n\n") {
167 out.pop();
168 }
169 if !out.ends_with('\n') {
170 out.push('\n');
171 }
172 out
173}
174
175fn render_test_case(
176 out: &mut String,
177 fixture: &Fixture,
178 function_name: &str,
179 result_var: &str,
180 args: &[crate::config::ArgMapping],
181 field_resolver: &FieldResolver,
182 result_is_simple: bool,
183) {
184 let test_name = sanitize_ident(&fixture.id);
185 let description = fixture.description.replace('"', "\\\"");
186
187 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
188
189 let args_str = build_args_string(&fixture.input, args);
190
191 if expects_error {
192 let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
193 let _ = writeln!(out, " expect_error({function_name}({args_str}))");
194 let _ = writeln!(out, "}})");
195 return;
196 }
197
198 let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
199 let _ = writeln!(out, " {result_var} <- {function_name}({args_str})");
200
201 for assertion in &fixture.assertions {
202 render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
203 }
204
205 let _ = writeln!(out, "}})");
206}
207
208fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
209 if args.is_empty() {
210 return json_to_r(input, true);
211 }
212
213 let parts: Vec<String> = args
214 .iter()
215 .filter_map(|arg| {
216 let val = input.get(&arg.field)?;
217 if val.is_null() && arg.optional {
218 return None;
219 }
220 Some(format!("{} = {}", arg.name, json_to_r(val, true)))
221 })
222 .collect();
223
224 parts.join(", ")
225}
226
227fn render_assertion(
228 out: &mut String,
229 assertion: &Assertion,
230 result_var: &str,
231 field_resolver: &FieldResolver,
232 result_is_simple: bool,
233) {
234 if let Some(f) = &assertion.field {
236 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
237 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
238 return;
239 }
240 }
241
242 if result_is_simple {
245 if let Some(f) = &assertion.field {
246 let f_lower = f.to_lowercase();
247 if !f.is_empty()
248 && f_lower != "content"
249 && (f_lower.starts_with("metadata")
250 || f_lower.starts_with("document")
251 || f_lower.starts_with("structure"))
252 {
253 let _ = writeln!(out, " # TODO: skipped (result_is_simple, field: {f})");
254 return;
255 }
256 }
257 }
258
259 let field_expr = if result_is_simple {
260 result_var.to_string()
261 } else {
262 match &assertion.field {
263 Some(f) if !f.is_empty() => field_resolver.accessor(f, "r", result_var),
264 _ => result_var.to_string(),
265 }
266 };
267
268 match assertion.assertion_type.as_str() {
269 "equals" => {
270 if let Some(expected) = &assertion.value {
271 let r_val = json_to_r(expected, false);
272 let _ = writeln!(out, " expect_equal(trimws({field_expr}), {r_val})");
273 }
274 }
275 "contains" => {
276 if let Some(expected) = &assertion.value {
277 let r_val = json_to_r(expected, false);
278 let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
279 }
280 }
281 "contains_all" => {
282 if let Some(values) = &assertion.values {
283 for val in values {
284 let r_val = json_to_r(val, false);
285 let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
286 }
287 }
288 }
289 "not_contains" => {
290 if let Some(expected) = &assertion.value {
291 let r_val = json_to_r(expected, false);
292 let _ = writeln!(out, " expect_false(grepl({r_val}, {field_expr}, fixed = TRUE))");
293 }
294 }
295 "not_empty" => {
296 let _ = writeln!(
297 out,
298 " expect_true(if (is.character({field_expr})) nchar({field_expr}) > 0 else length({field_expr}) > 0)"
299 );
300 }
301 "is_empty" => {
302 let _ = writeln!(out, " expect_equal({field_expr}, \"\")");
303 }
304 "contains_any" => {
305 if let Some(values) = &assertion.values {
306 let items: Vec<String> = values.iter().map(|v| json_to_r(v, false)).collect();
307 let vec_str = items.join(", ");
308 let _ = writeln!(
309 out,
310 " expect_true(any(sapply(c({vec_str}), function(v) grepl(v, {field_expr}, fixed = TRUE))))"
311 );
312 }
313 }
314 "greater_than" => {
315 if let Some(val) = &assertion.value {
316 let r_val = json_to_r(val, false);
317 let _ = writeln!(out, " expect_true({field_expr} > {r_val})");
318 }
319 }
320 "less_than" => {
321 if let Some(val) = &assertion.value {
322 let r_val = json_to_r(val, false);
323 let _ = writeln!(out, " expect_true({field_expr} < {r_val})");
324 }
325 }
326 "greater_than_or_equal" => {
327 if let Some(val) = &assertion.value {
328 let r_val = json_to_r(val, false);
329 let _ = writeln!(out, " expect_true({field_expr} >= {r_val})");
330 }
331 }
332 "less_than_or_equal" => {
333 if let Some(val) = &assertion.value {
334 let r_val = json_to_r(val, false);
335 let _ = writeln!(out, " expect_true({field_expr} <= {r_val})");
336 }
337 }
338 "starts_with" => {
339 if let Some(expected) = &assertion.value {
340 let r_val = json_to_r(expected, false);
341 let _ = writeln!(out, " expect_true(startsWith({field_expr}, {r_val}))");
342 }
343 }
344 "ends_with" => {
345 if let Some(expected) = &assertion.value {
346 let r_val = json_to_r(expected, false);
347 let _ = writeln!(out, " expect_true(endsWith({field_expr}, {r_val}))");
348 }
349 }
350 "min_length" => {
351 if let Some(val) = &assertion.value {
352 if let Some(n) = val.as_u64() {
353 let _ = writeln!(out, " expect_true(nchar({field_expr}) >= {n})");
354 }
355 }
356 }
357 "max_length" => {
358 if let Some(val) = &assertion.value {
359 if let Some(n) = val.as_u64() {
360 let _ = writeln!(out, " expect_true(nchar({field_expr}) <= {n})");
361 }
362 }
363 }
364 "count_min" => {
365 if let Some(val) = &assertion.value {
366 if let Some(n) = val.as_u64() {
367 let _ = writeln!(out, " expect_true(length({field_expr}) >= {n})");
368 }
369 }
370 }
371 "not_error" => {
372 }
374 "error" => {
375 }
377 other => {
378 let _ = writeln!(out, " # TODO: unsupported assertion type: {other}");
379 }
380 }
381}
382
383fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
391 match value {
392 serde_json::Value::String(s) => {
393 let normalized = if lowercase_enum_values && s.chars().next().is_some_and(|c| c.is_uppercase()) {
395 s.to_lowercase()
396 } else {
397 s.clone()
398 };
399 format!("\"{}\"", escape_r(&normalized))
400 }
401 serde_json::Value::Bool(true) => "TRUE".to_string(),
402 serde_json::Value::Bool(false) => "FALSE".to_string(),
403 serde_json::Value::Number(n) => n.to_string(),
404 serde_json::Value::Null => "NULL".to_string(),
405 serde_json::Value::Array(arr) => {
406 let items: Vec<String> = arr.iter().map(|v| json_to_r(v, lowercase_enum_values)).collect();
407 format!("c({})", items.join(", "))
408 }
409 serde_json::Value::Object(map) => {
410 let entries: Vec<String> = map
411 .iter()
412 .map(|(k, v)| format!("\"{}\" = {}", escape_r(k), json_to_r(v, lowercase_enum_values)))
413 .collect();
414 format!("list({})", entries.join(", "))
415 }
416 }
417}