diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 5e2693e..c258bb4 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -47,8 +47,6 @@ jobs: any::rcmdcheck upgrade: 'TRUE' - uses: r-lib/actions/check-r-package@v2 - with: - error-on: '"note"' - name: Check pkgdown shell: Rscript {0} run: pkgdown::check_pkgdown() diff --git a/DESCRIPTION b/DESCRIPTION index ed5bb9c..f188968 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -17,6 +17,7 @@ Roxygen: list(markdown = TRUE) RoxygenNote: 7.3.3 Imports: dplyr, + callr, magrittr (>= 2.0.3), XML, utils, @@ -25,6 +26,7 @@ Imports: stringr, fs, cli, + here, rmarkdown, tinytex, pmtables, @@ -34,7 +36,9 @@ Imports: htmltools, shiny, pdftools, - stringi + stringi, + yaml, + purrr Suggests: knitr, testthat (>= 3.0.0), diff --git a/NAMESPACE b/NAMESPACE index 2cdafe9..b6825d5 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -21,6 +21,7 @@ export(logSummary) export(renderQCReport) export(renderQCSummary) export(repoHistory) +export(runWithOutputs) export(svnExport) export(svnLog) export(with_demoRepo) diff --git a/R/reviewPackage.R b/R/reviewPackage.R index 0ef8bd9..38a904a 100644 --- a/R/reviewPackage.R +++ b/R/reviewPackage.R @@ -29,6 +29,9 @@ globalVariables( "path", "page", "type", - "code_path" + "code_path", + "rel_path", + "modification_time", + "size" ) -) \ No newline at end of file +) diff --git a/R/runWithOutputs.R b/R/runWithOutputs.R new file mode 100644 index 0000000..6c57134 --- /dev/null +++ b/R/runWithOutputs.R @@ -0,0 +1,90 @@ +#' Execute Script and Detect File Changes +#' +#' Runs an R script in an isolated session and identifies files that were created +#' or modified during execution. It detects changes by comparing the file system +#' state (modification time and size) before and after the run. +#' +#' @param script Character. The path to the R script to execute. +#' @param root Character. The project root directory. Defaults to `here::here()`. +#' @param exclude_dirs Character vector. A list of top-level directories relative +#' to `root` to ignore when scanning for changes (e.g., "renv", ".git"). +#' +#' @return Invisibly returns a character vector of relative paths for all files +#' that were created or updated. +#' @export +runWithOutputs <- function( + script, + root = here::here(), + exclude_dirs = c("renv", ".svn", ".git") +) { + # Normalize paths and calculate relative path for the UI + script_abs <- fs::path_abs(script) + script_rel <- fs::path_rel(script_abs, start = root) + + # 1. Capture Initial State + # Define file state by path, modification time, and size. + before <- fs::dir_info(root, recurse = TRUE, type = "file") %>% + dplyr::select(path, modification_time, size) + + # --- UI Header --- + # Shows the relative path in the badge for immediate context. + div_start <- cli::cli_div(theme = list(rule = list(color = "cyan"))) + cli::cli_rule( + left = cli::style_bold(cli::bg_cyan(cli::col_white(paste0( + " runWithOutputs('", + script_rel, + "') " + )))), + right = "START" + ) + cli::cli_end(div_start) + + # 2. Execute Script + # Run in a clean, separate R session to ensure isolation. + callr::rscript(script_abs, wd = root, show = TRUE) + + # --- UI Footer --- + # Provides a clean "closing bracket" for the script output. + div_end <- cli::cli_div(theme = list(rule = list(color = "cyan"))) + cli::cli_rule( + left = cli::style_bold(cli::bg_cyan(cli::col_white(" runWithOutputs() "))), + right = "COMPLETE" + ) + cli::cli_end(div_end) + + # 3. Capture Final State + after <- fs::dir_info(root, recurse = TRUE, type = "file") %>% + dplyr::select(path, modification_time, size) + + # 4. Compute State Differences + # Identify files where the (path, time, size) tuple in the 'after' snapshot + # does not strictly match the 'before' snapshot. + changed <- dplyr::anti_join( + after, + before, + by = c("path", "modification_time", "size") + ) %>% + dplyr::mutate(rel_path = fs::path_rel(path, start = root)) + + # 5. Apply Exclusions + # Filter out files where the top-level directory matches an exclusion pattern. + if (length(exclude_dirs) > 0 && nrow(changed) > 0) { + changed <- changed %>% + dplyr::filter( + !fs::path_split(rel_path) %>% + purrr::map_lgl(~ .x[1] %in% exclude_dirs) + ) + } + + # 6. Report Results + out_paths <- sort(changed$rel_path) + + if (length(out_paths) > 0) { + cli::cli_alert_success("Files saved by this run:") + cli::cli_code(yaml::as.yaml(list(outputs = out_paths))) + invisible(out_paths) + } else { + cli::cli_alert_info(cli::col_silver("No files were saved.")) + invisible(character(0)) + } +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 2b777ba..f28297e 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -42,3 +42,7 @@ reference: - logRemove - repoHistory - getTFs + - title: Execution Tools + desc: Run scripts and track side effects + contents: + - runWithOutputs diff --git a/man/runWithOutputs.Rd b/man/runWithOutputs.Rd new file mode 100644 index 0000000..d8bae77 --- /dev/null +++ b/man/runWithOutputs.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/runWithOutputs.R +\name{runWithOutputs} +\alias{runWithOutputs} +\title{Execute Script and Detect File Changes} +\usage{ +runWithOutputs( + script, + root = here::here(), + exclude_dirs = c("renv", ".svn", ".git") +) +} +\arguments{ +\item{script}{Character. The path to the R script to execute.} + +\item{root}{Character. The project root directory. Defaults to \code{here::here()}.} + +\item{exclude_dirs}{Character vector. A list of top-level directories relative +to \code{root} to ignore when scanning for changes (e.g., "renv", ".git").} +} +\value{ +Invisibly returns a character vector of relative paths for all files +that were created or updated. +} +\description{ +Runs an R script in an isolated session and identifies files that were created +or modified during execution. It detects changes by comparing the file system +state (modification time and size) before and after the run. +} diff --git a/tests/testthat/test-runWithOutputs.R b/tests/testthat/test-runWithOutputs.R new file mode 100644 index 0000000..40d3f6c --- /dev/null +++ b/tests/testthat/test-runWithOutputs.R @@ -0,0 +1,147 @@ +test_that("runWithOutputs detects newly created files", { + with_demoRepo({ + # Setup: Create a script that generates a new output file + script_path <- "script/generate_data.R" + output_file <- "data/new_result.txt" + + fs::dir_create("script") + + writeLines( + text = c( + 'dir.create("data", showWarnings = FALSE)', + paste0('writeLines("content", "', output_file, '")') + ), + con = script_path + ) + + # Execution + result <- runWithOutputs(script_path, root = getwd()) + + # Verification: Ensure only the expected output file is returned + expect_setequal(result, output_file) + }) +}) + +test_that("runWithOutputs detects modified files", { + with_demoRepo({ + # Setup: Create an existing file + target_file <- "data/existing_file.txt" + fs::dir_create("data") + writeLines("old content", target_file) + + # Backdate the file timestamp to ensure detection without Sys.sleep() + Sys.setFileTime(target_file, Sys.time() - 60) + + script_path <- "script/modify_data.R" + fs::dir_create("script") + + writeLines( + text = paste0('writeLines("new content", "', target_file, '")'), + con = script_path + ) + + # Execution + result <- runWithOutputs(script_path, root = getwd()) + + # Verification + expect_true(target_file %in% result) + }) +}) + +test_that("runWithOutputs detects file 'touch' (overwrite with identical content)", { + with_demoRepo({ + target_file <- "data/config.yml" + fs::dir_create("data") + content <- "fixed_settings: true" + + # 1. Create file + writeLines(content, target_file) + + # 2. Backdate it + Sys.setFileTime(target_file, Sys.time() - 60) + + # 3. Script overwrites it with EXACT SAME content + script_path <- "script/refresh_config.R" + fs::dir_create("script") + writeLines( + text = paste0('writeLines("', content, '", "', target_file, '")'), + con = script_path + ) + + # 4. Execution + result <- runWithOutputs(script_path, root = getwd()) + + # 5. Verification: Identical size but new timestamp should trigger detection + expect_true(target_file %in% result) + }) +}) + +test_that("runWithOutputs returns empty vector when no files change", { + with_demoRepo({ + script_path <- "script/do_nothing.R" + fs::dir_create("script") + writeLines("print('No file changes here')", script_path) + + # Execution + result <- runWithOutputs(script_path, root = getwd()) + + expect_identical(result, character(0)) + }) +}) + +test_that("runWithOutputs respects default excluded directories", { + with_demoRepo({ + fs::dir_create("renv") + fs::dir_create("scratch") + fs::dir_create("data") + fs::dir_create("script") + + script_path <- "script/mixed_outputs.R" + + # Script writes to an ignored dir and two included dirs + writeLines( + text = c( + 'writeLines("a", "renv/ignored.txt")', + 'writeLines("b", "scratch/included.txt")', + 'writeLines("c", "data/important.txt")' + ), + con = script_path + ) + + # Execution + result <- runWithOutputs(script_path, root = getwd()) + + # Verification + expect_true("data/important.txt" %in% result) + expect_true("scratch/included.txt" %in% result) + expect_false("renv/ignored.txt" %in% result) + }) +}) + +test_that("runWithOutputs respects custom excluded directories argument", { + with_demoRepo({ + fs::dir_create("custom_folder") + script_path <- "script.R" + + writeLines('writeLines("x", "custom_folder/file.txt")', script_path) + + # Pass a custom exclusion list + result <- runWithOutputs( + script_path, + root = getwd(), + exclude_dirs = c("custom_folder") + ) + + expect_false("custom_folder/file.txt" %in% result) + }) +}) + +test_that("runWithOutputs errors if the script execution fails", { + with_demoRepo({ + script_path <- "broken.R" + writeLines("stop('Critical error')", script_path) + + # Verification: callr should propagate the error to runWithOutputs + expect_error(runWithOutputs(script_path, root = getwd())) + }) +})