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_is_simple = overrides.is_some_and(|o| o.result_is_simple);
42        let result_var = &call.result_var;
43
44        // Resolve package config.
45        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        // Generate DESCRIPTION file.
56        files.push(GeneratedFile {
57            path: output_base.join("DESCRIPTION"),
58            content: render_description(&pkg_name),
59            generated_header: false,
60        });
61
62        // Generate test runner script.
63        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        // Generate test files per category.
70        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    // Use devtools::load_all() to load the local R package without requiring
129    // a full install, matching the e2e test runner convention.
130    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    // Clean up trailing newlines.
166    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    // Skip assertions on fields that don't exist on the result type.
235    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    // When result_is_simple, skip assertions that reference non-content fields
243    // (e.g., metadata, document, structure) since the binding returns a plain value.
244    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            // Already handled — the call would stop on error.
373        }
374        "error" => {
375            // Handled at the test level.
376        }
377        other => {
378            let _ = writeln!(out, "  # TODO: unsupported assertion type: {other}");
379        }
380    }
381}
382
383/// Convert a `serde_json::Value` to an R literal string.
384///
385/// # Arguments
386///
387/// * `value` - The JSON value to convert
388/// * `lowercase_enum_values` - If true, lowercase strings starting with uppercase letter (for enum values).
389///   If false, preserve original case (for assertion expected values).
390fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
391    match value {
392        serde_json::Value::String(s) => {
393            // Lowercase enum values (strings starting with uppercase letter) only if requested
394            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}