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