Skip to main content

alef_e2e/codegen/
r.rs

1//! R e2e test generator using testthat.
2
3use 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
15/// R e2e code generator.
16pub 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        // Resolve call config with overrides.
31        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_var = &call.result_var;
42
43        // Resolve package config.
44        let r_pkg = e2e_config.packages.get("r");
45        let pkg_name = r_pkg
46            .and_then(|p| p.name.as_ref())
47            .cloned()
48            .unwrap_or_else(|| module_path.clone());
49
50        // Generate DESCRIPTION file.
51        files.push(GeneratedFile {
52            path: output_base.join("DESCRIPTION"),
53            content: render_description(&pkg_name),
54            generated_header: false,
55        });
56
57        // Generate test runner script.
58        files.push(GeneratedFile {
59            path: output_base.join("run_tests.R"),
60            content: render_test_runner(&pkg_name),
61            generated_header: true,
62        });
63
64        // Generate test files per category.
65        for group in groups {
66            let active: Vec<&Fixture> = group
67                .fixtures
68                .iter()
69                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
70                .collect();
71
72            if active.is_empty() {
73                continue;
74            }
75
76            let filename = format!("test_{}.R", sanitize_filename(&group.category));
77            let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
78            let content = render_test_file(
79                &group.category,
80                &active,
81                &function_name,
82                result_var,
83                &e2e_config.call.args,
84                &field_resolver,
85            );
86            files.push(GeneratedFile {
87                path: output_base.join("tests").join(filename),
88                content,
89                generated_header: true,
90            });
91        }
92
93        Ok(files)
94    }
95
96    fn language_name(&self) -> &'static str {
97        "r"
98    }
99}
100
101fn render_description(pkg_name: &str) -> String {
102    format!(
103        r#"Package: e2e.r
104Title: E2E Tests for {pkg_name}
105Version: 0.1.0
106Description: End-to-end test suite.
107Suggests: testthat (>= 3.0.0)
108Config/testthat/edition: 3
109"#
110    )
111}
112
113fn render_test_runner(pkg_name: &str) -> String {
114    let mut out = String::new();
115    let _ = writeln!(out, "library(testthat)");
116    let _ = writeln!(out, "library({pkg_name})");
117    let _ = writeln!(out);
118    let _ = writeln!(out, "test_dir(\"tests\")");
119    out
120}
121
122fn render_test_file(
123    category: &str,
124    fixtures: &[&Fixture],
125    function_name: &str,
126    result_var: &str,
127    args: &[crate::config::ArgMapping],
128    field_resolver: &FieldResolver,
129) -> String {
130    let mut out = String::new();
131    let _ = writeln!(out, "# E2e tests for category: {category}");
132    let _ = writeln!(out);
133
134    for (i, fixture) in fixtures.iter().enumerate() {
135        render_test_case(&mut out, fixture, function_name, result_var, args, field_resolver);
136        if i + 1 < fixtures.len() {
137            let _ = writeln!(out);
138        }
139    }
140
141    // Clean up trailing newlines.
142    while out.ends_with("\n\n") {
143        out.pop();
144    }
145    if !out.ends_with('\n') {
146        out.push('\n');
147    }
148    out
149}
150
151fn render_test_case(
152    out: &mut String,
153    fixture: &Fixture,
154    function_name: &str,
155    result_var: &str,
156    args: &[crate::config::ArgMapping],
157    field_resolver: &FieldResolver,
158) {
159    let test_name = sanitize_ident(&fixture.id);
160    let description = fixture.description.replace('"', "\\\"");
161
162    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
163
164    let args_str = build_args_string(&fixture.input, args);
165
166    if expects_error {
167        let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
168        let _ = writeln!(out, "  expect_error({function_name}({args_str}))");
169        let _ = writeln!(out, "}})");
170        return;
171    }
172
173    let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
174    let _ = writeln!(out, "  {result_var} <- {function_name}({args_str})");
175
176    for assertion in &fixture.assertions {
177        render_assertion(out, assertion, result_var, field_resolver);
178    }
179
180    let _ = writeln!(out, "}})");
181}
182
183fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
184    if args.is_empty() {
185        return json_to_r(input);
186    }
187
188    let parts: Vec<String> = args
189        .iter()
190        .filter_map(|arg| {
191            let val = input.get(&arg.field)?;
192            if val.is_null() && arg.optional {
193                return None;
194            }
195            Some(format!("{} = {}", arg.name, json_to_r(val)))
196        })
197        .collect();
198
199    parts.join(", ")
200}
201
202fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
203    let field_expr = match &assertion.field {
204        Some(f) if !f.is_empty() => field_resolver.accessor(f, "r", result_var),
205        _ => result_var.to_string(),
206    };
207
208    match assertion.assertion_type.as_str() {
209        "equals" => {
210            if let Some(expected) = &assertion.value {
211                let r_val = json_to_r(expected);
212                let _ = writeln!(out, "  expect_equal(trimws({field_expr}), {r_val})");
213            }
214        }
215        "contains" => {
216            if let Some(expected) = &assertion.value {
217                let r_val = json_to_r(expected);
218                let _ = writeln!(out, "  expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
219            }
220        }
221        "contains_all" => {
222            if let Some(values) = &assertion.values {
223                for val in values {
224                    let r_val = json_to_r(val);
225                    let _ = writeln!(out, "  expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
226                }
227            }
228        }
229        "not_contains" => {
230            if let Some(expected) = &assertion.value {
231                let r_val = json_to_r(expected);
232                let _ = writeln!(out, "  expect_false(grepl({r_val}, {field_expr}, fixed = TRUE))");
233            }
234        }
235        "not_empty" => {
236            let _ = writeln!(out, "  expect_true(nchar({field_expr}) > 0)");
237        }
238        "is_empty" => {
239            let _ = writeln!(out, "  expect_equal({field_expr}, \"\")");
240        }
241        "starts_with" => {
242            if let Some(expected) = &assertion.value {
243                let r_val = json_to_r(expected);
244                let _ = writeln!(out, "  expect_true(startsWith({field_expr}, {r_val}))");
245            }
246        }
247        "ends_with" => {
248            if let Some(expected) = &assertion.value {
249                let r_val = json_to_r(expected);
250                let _ = writeln!(out, "  expect_true(endsWith({field_expr}, {r_val}))");
251            }
252        }
253        "min_length" => {
254            if let Some(val) = &assertion.value {
255                if let Some(n) = val.as_u64() {
256                    let _ = writeln!(out, "  expect_true(nchar({field_expr}) >= {n})");
257                }
258            }
259        }
260        "max_length" => {
261            if let Some(val) = &assertion.value {
262                if let Some(n) = val.as_u64() {
263                    let _ = writeln!(out, "  expect_true(nchar({field_expr}) <= {n})");
264                }
265            }
266        }
267        "not_error" => {
268            // Already handled — the call would stop on error.
269        }
270        "error" => {
271            // Handled at the test level.
272        }
273        other => {
274            let _ = writeln!(out, "  # TODO: unsupported assertion type: {other}");
275        }
276    }
277}
278
279/// Convert a `serde_json::Value` to an R literal string.
280fn json_to_r(value: &serde_json::Value) -> String {
281    match value {
282        serde_json::Value::String(s) => format!("\"{}\"", escape_r(s)),
283        serde_json::Value::Bool(true) => "TRUE".to_string(),
284        serde_json::Value::Bool(false) => "FALSE".to_string(),
285        serde_json::Value::Number(n) => n.to_string(),
286        serde_json::Value::Null => "NULL".to_string(),
287        serde_json::Value::Array(arr) => {
288            let items: Vec<String> = arr.iter().map(json_to_r).collect();
289            format!("c({})", items.join(", "))
290        }
291        serde_json::Value::Object(map) => {
292            let entries: Vec<String> = map
293                .iter()
294                .map(|(k, v)| format!("\"{}\" = {}", escape_r(k), json_to_r(v)))
295                .collect();
296            format!("list({})", entries.join(", "))
297        }
298    }
299}