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.effective_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.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        // Generate DESCRIPTION file.
63        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        // Generate test runner script.
70        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        // Generate test files per category.
77        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            // In registry mode, require the installed CRAN package directly.
144            let _ = writeln!(out, "# Package loaded via library() from CRAN install.");
145        }
146        crate::config::DependencyMode::Local => {
147            // Use devtools::load_all() to load the local R package without requiring
148            // a full install, matching the e2e test runner convention.
149            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    // Clean up trailing newlines.
187    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    // Skip assertions on fields that don't exist on the result type.
256    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    // When result_is_simple, skip assertions that reference non-content fields
264    // (e.g., metadata, document, structure) since the binding returns a plain value.
265    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            // Already handled — the call would stop on error.
394        }
395        "error" => {
396            // Handled at the test level.
397        }
398        other => {
399            let _ = writeln!(out, "  # TODO: unsupported assertion type: {other}");
400        }
401    }
402}
403
404/// Convert a `serde_json::Value` to an R literal string.
405///
406/// # Arguments
407///
408/// * `value` - The JSON value to convert
409/// * `lowercase_enum_values` - If true, lowercase strings starting with uppercase letter (for enum values).
410///   If false, preserve original case (for assertion expected values).
411fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
412    match value {
413        serde_json::Value::String(s) => {
414            // Lowercase enum values (strings starting with uppercase letter) only if requested
415            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}