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, CallbackAction, 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    // Build visitor setup and args if present
213    let mut setup_lines = Vec::new();
214    let final_args = if let Some(visitor_spec) = &fixture.visitor {
215        build_r_visitor(&mut setup_lines, visitor_spec);
216        if args_str.is_empty() {
217            "visitor".to_string()
218        } else {
219            format!("{args_str}, visitor = visitor")
220        }
221    } else {
222        args_str
223    };
224
225    if expects_error {
226        let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
227        for line in &setup_lines {
228            let _ = writeln!(out, "  {line}");
229        }
230        let _ = writeln!(out, "  expect_error({function_name}({final_args}))");
231        let _ = writeln!(out, "}})");
232        return;
233    }
234
235    let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
236    for line in &setup_lines {
237        let _ = writeln!(out, "  {line}");
238    }
239    let _ = writeln!(out, "  {result_var} <- {function_name}({final_args})");
240
241    for assertion in &fixture.assertions {
242        render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
243    }
244
245    let _ = writeln!(out, "}})");
246}
247
248fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
249    if args.is_empty() {
250        return json_to_r(input, true);
251    }
252
253    let parts: Vec<String> = args
254        .iter()
255        .filter_map(|arg| {
256            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
257            let val = input.get(field)?;
258            if val.is_null() && arg.optional {
259                return None;
260            }
261            Some(format!("{} = {}", arg.name, json_to_r(val, true)))
262        })
263        .collect();
264
265    parts.join(", ")
266}
267
268fn render_assertion(
269    out: &mut String,
270    assertion: &Assertion,
271    result_var: &str,
272    field_resolver: &FieldResolver,
273    result_is_simple: bool,
274) {
275    // Skip assertions on fields that don't exist on the result type.
276    if let Some(f) = &assertion.field {
277        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
278            let _ = writeln!(out, "  # skipped: field '{f}' not available on result type");
279            return;
280        }
281    }
282
283    // When result_is_simple, skip assertions that reference non-content fields
284    // (e.g., metadata, document, structure) since the binding returns a plain value.
285    if result_is_simple {
286        if let Some(f) = &assertion.field {
287            let f_lower = f.to_lowercase();
288            if !f.is_empty()
289                && f_lower != "content"
290                && (f_lower.starts_with("metadata")
291                    || f_lower.starts_with("document")
292                    || f_lower.starts_with("structure"))
293            {
294                let _ = writeln!(out, "  # TODO: skipped (result_is_simple, field: {f})");
295                return;
296            }
297        }
298    }
299
300    let field_expr = if result_is_simple {
301        result_var.to_string()
302    } else {
303        match &assertion.field {
304            Some(f) if !f.is_empty() => field_resolver.accessor(f, "r", result_var),
305            _ => result_var.to_string(),
306        }
307    };
308
309    match assertion.assertion_type.as_str() {
310        "equals" => {
311            if let Some(expected) = &assertion.value {
312                let r_val = json_to_r(expected, false);
313                let _ = writeln!(out, "  expect_equal(trimws({field_expr}), {r_val})");
314            }
315        }
316        "contains" => {
317            if let Some(expected) = &assertion.value {
318                let r_val = json_to_r(expected, false);
319                let _ = writeln!(out, "  expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
320            }
321        }
322        "contains_all" => {
323            if let Some(values) = &assertion.values {
324                for val in values {
325                    let r_val = json_to_r(val, false);
326                    let _ = writeln!(out, "  expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
327                }
328            }
329        }
330        "not_contains" => {
331            if let Some(expected) = &assertion.value {
332                let r_val = json_to_r(expected, false);
333                let _ = writeln!(out, "  expect_false(grepl({r_val}, {field_expr}, fixed = TRUE))");
334            }
335        }
336        "not_empty" => {
337            let _ = writeln!(
338                out,
339                "  expect_true(if (is.character({field_expr})) nchar({field_expr}) > 0 else length({field_expr}) > 0)"
340            );
341        }
342        "is_empty" => {
343            let _ = writeln!(out, "  expect_equal({field_expr}, \"\")");
344        }
345        "contains_any" => {
346            if let Some(values) = &assertion.values {
347                let items: Vec<String> = values.iter().map(|v| json_to_r(v, false)).collect();
348                let vec_str = items.join(", ");
349                let _ = writeln!(
350                    out,
351                    "  expect_true(any(sapply(c({vec_str}), function(v) grepl(v, {field_expr}, fixed = TRUE))))"
352                );
353            }
354        }
355        "greater_than" => {
356            if let Some(val) = &assertion.value {
357                let r_val = json_to_r(val, false);
358                let _ = writeln!(out, "  expect_true({field_expr} > {r_val})");
359            }
360        }
361        "less_than" => {
362            if let Some(val) = &assertion.value {
363                let r_val = json_to_r(val, false);
364                let _ = writeln!(out, "  expect_true({field_expr} < {r_val})");
365            }
366        }
367        "greater_than_or_equal" => {
368            if let Some(val) = &assertion.value {
369                let r_val = json_to_r(val, false);
370                let _ = writeln!(out, "  expect_true({field_expr} >= {r_val})");
371            }
372        }
373        "less_than_or_equal" => {
374            if let Some(val) = &assertion.value {
375                let r_val = json_to_r(val, false);
376                let _ = writeln!(out, "  expect_true({field_expr} <= {r_val})");
377            }
378        }
379        "starts_with" => {
380            if let Some(expected) = &assertion.value {
381                let r_val = json_to_r(expected, false);
382                let _ = writeln!(out, "  expect_true(startsWith({field_expr}, {r_val}))");
383            }
384        }
385        "ends_with" => {
386            if let Some(expected) = &assertion.value {
387                let r_val = json_to_r(expected, false);
388                let _ = writeln!(out, "  expect_true(endsWith({field_expr}, {r_val}))");
389            }
390        }
391        "min_length" => {
392            if let Some(val) = &assertion.value {
393                if let Some(n) = val.as_u64() {
394                    let _ = writeln!(out, "  expect_true(nchar({field_expr}) >= {n})");
395                }
396            }
397        }
398        "max_length" => {
399            if let Some(val) = &assertion.value {
400                if let Some(n) = val.as_u64() {
401                    let _ = writeln!(out, "  expect_true(nchar({field_expr}) <= {n})");
402                }
403            }
404        }
405        "count_min" => {
406            if let Some(val) = &assertion.value {
407                if let Some(n) = val.as_u64() {
408                    let _ = writeln!(out, "  expect_true(length({field_expr}) >= {n})");
409                }
410            }
411        }
412        "count_equals" => {
413            if let Some(val) = &assertion.value {
414                if let Some(n) = val.as_u64() {
415                    let _ = writeln!(out, "  expect_equal(length({field_expr}), {n})");
416                }
417            }
418        }
419        "is_true" => {
420            let _ = writeln!(out, "  expect_true({field_expr})");
421        }
422        "not_error" => {
423            // Already handled — the call would stop on error.
424        }
425        "error" => {
426            // Handled at the test level.
427        }
428        other => {
429            let _ = writeln!(out, "  # TODO: unsupported assertion type: {other}");
430        }
431    }
432}
433
434/// Convert a `serde_json::Value` to an R literal string.
435///
436/// # Arguments
437///
438/// * `value` - The JSON value to convert
439/// * `lowercase_enum_values` - If true, lowercase strings starting with uppercase letter (for enum values).
440///   If false, preserve original case (for assertion expected values).
441fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
442    match value {
443        serde_json::Value::String(s) => {
444            // Lowercase enum values (strings starting with uppercase letter) only if requested
445            let normalized = if lowercase_enum_values && s.chars().next().is_some_and(|c| c.is_uppercase()) {
446                s.to_lowercase()
447            } else {
448                s.clone()
449            };
450            format!("\"{}\"", escape_r(&normalized))
451        }
452        serde_json::Value::Bool(true) => "TRUE".to_string(),
453        serde_json::Value::Bool(false) => "FALSE".to_string(),
454        serde_json::Value::Number(n) => n.to_string(),
455        serde_json::Value::Null => "NULL".to_string(),
456        serde_json::Value::Array(arr) => {
457            let items: Vec<String> = arr.iter().map(|v| json_to_r(v, lowercase_enum_values)).collect();
458            format!("c({})", items.join(", "))
459        }
460        serde_json::Value::Object(map) => {
461            let entries: Vec<String> = map
462                .iter()
463                .map(|(k, v)| format!("\"{}\" = {}", escape_r(k), json_to_r(v, lowercase_enum_values)))
464                .collect();
465            format!("list({})", entries.join(", "))
466        }
467    }
468}
469
470/// Build an R visitor list and add setup line.
471fn build_r_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
472    use std::fmt::Write as FmtWrite;
473    let mut visitor_obj = String::new();
474    let _ = writeln!(visitor_obj, "list(");
475    for (method_name, action) in &visitor_spec.callbacks {
476        emit_r_visitor_method(&mut visitor_obj, method_name, action);
477    }
478    let _ = writeln!(visitor_obj, "  )");
479
480    setup_lines.push(format!("visitor <- {visitor_obj}"));
481}
482
483/// Emit an R visitor method for a callback action.
484fn emit_r_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
485    use std::fmt::Write as FmtWrite;
486
487    // R uses visit_ prefix (matches binding signature)
488    let params = match method_name {
489        "visit_link" => "ctx, href, text, title",
490        "visit_image" => "ctx, src, alt, title",
491        "visit_heading" => "ctx, level, text, id",
492        "visit_code_block" => "ctx, lang, code",
493        "visit_code_inline"
494        | "visit_strong"
495        | "visit_emphasis"
496        | "visit_strikethrough"
497        | "visit_underline"
498        | "visit_subscript"
499        | "visit_superscript"
500        | "visit_mark"
501        | "visit_button"
502        | "visit_summary"
503        | "visit_figcaption"
504        | "visit_definition_term"
505        | "visit_definition_description" => "ctx, text",
506        "visit_text" => "ctx, text",
507        "visit_list_item" => "ctx, ordered, marker, text",
508        "visit_blockquote" => "ctx, content, depth",
509        "visit_table_row" => "ctx, cells, is_header",
510        "visit_custom_element" => "ctx, tag_name, html",
511        "visit_form" => "ctx, action_url, method",
512        "visit_input" => "ctx, input_type, name, value",
513        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
514        "visit_details" => "ctx, is_open",
515        _ => "ctx",
516    };
517
518    let _ = writeln!(out, "    {method_name} = function({params}) {{");
519    match action {
520        CallbackAction::Skip => {
521            let _ = writeln!(out, "      \"skip\"");
522        }
523        CallbackAction::Continue => {
524            let _ = writeln!(out, "      \"continue\"");
525        }
526        CallbackAction::PreserveHtml => {
527            let _ = writeln!(out, "      \"preserve_html\"");
528        }
529        CallbackAction::Custom { output } => {
530            let escaped = escape_r(output);
531            let _ = writeln!(out, "      list(custom = {escaped})");
532        }
533        CallbackAction::CustomTemplate { template } => {
534            let escaped = escape_r(template);
535            let _ = writeln!(out, "      list(custom = {escaped})");
536        }
537    }
538    let _ = writeln!(out, "    }},");
539}