From c7098b6673c37f395aed529e37186f4d44e5c224 Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Mon, 22 Jun 2026 22:09:27 -0500 Subject: [PATCH 1/6] Fix latent bugs in installer and toolchain code - openmp_uninstall(): reference the configure_makevars argument instead of an undefined variable that errored after removing files. - tar_package_install(): treat any non-zero exit as failure (was status < 0, which missed real tar failures). - dmg_package_install(): unmount the volume before aborting so a failed install does not leak the mount. - xcode_cli_install(): remove the temp file before aborting (was unreachable after cli_abort). - gfortran(): carry the error message in $stdout so callers stop silently dropping it. - macos_rtools_install()/uninstall(): advance the progress bar via id=/set= instead of passing the handle as inc. - configure_openmp_makevars(): actually return early when ~/.R cannot be created. --- R/gfortran.R | 2 +- R/installers.R | 9 ++++----- R/openmp.R | 21 ++++++++++++--------- R/toolchain.R | 28 ++++++++++++++-------------- R/xcode.R | 10 ++++------ tests/testthat/test-gfortran.R | 10 ++++++++++ tests/testthat/test-installers.R | 16 ++++++++++++++++ 7 files changed, 61 insertions(+), 35 deletions(-) diff --git a/R/gfortran.R b/R/gfortran.R index f7d7f19..da8f401 100644 --- a/R/gfortran.R +++ b/R/gfortran.R @@ -474,7 +474,7 @@ gfortran <- function(args) { sys::exec_internal("gfortran", args = args, error = FALSE), error = function(e) { base::list( - output = e, + stdout = base::charToRaw(base::paste0(base::conditionMessage(e), "\n")), status = -127L ) } diff --git a/R/installers.R b/R/installers.R index 30167c7..d8bf2bc 100644 --- a/R/installers.R +++ b/R/installers.R @@ -275,7 +275,7 @@ tar_package_install <- function(path_to_tar, status <- shell_execute(cmd, sudo = sudo, password = password, verbose = verbose) # Verify installation is okay: - if (status < 0) { + if (status != 0) { cli::cli_abort("{.pkg macrtools}: Failed to install from {.path {path_to_tar}}") } @@ -347,15 +347,14 @@ dmg_package_install <- function(path_to_dmg, install_status <- shell_execute(cmd, sudo = TRUE, password = password) if (install_status != 0) { + # Unmount the volume before surfacing the failure so we do not leak it. + cmd <- base::paste("hdiutil", "detach", base::shQuote(base::file.path("/Volumes", bare_volume))) + shell_execute(cmd, sudo = FALSE) cli::cli_abort(c( "{.pkg macrtools}: Failed to install package from disk image.", "Disk image: {.file {volume_with_extension}}", "Status code: {.val {install_status}}" )) - # Attempt to unmount anyway - cmd <- base::paste("hdiutil", "detach", base::shQuote(base::file.path("/Volumes", bare_volume))) - shell_execute(cmd, sudo = FALSE) - return(FALSE) } if (verbose) { diff --git a/R/openmp.R b/R/openmp.R index afa9109..ef1f6c5 100644 --- a/R/openmp.R +++ b/R/openmp.R @@ -298,7 +298,7 @@ openmp_uninstall <- function(password = base::getOption("macrtools.password"), } # Remove Makevars configuration if requested - if (remove_makevars_config) { + if (configure_makevars) { makevars_removal_status <- remove_openmp_makevars_config(verbose = verbose) if (!makevars_removal_status && verbose) { cli::cli_alert_warning("{.pkg macrtools}: OpenMP uninstalled but Makevars cleanup failed.") @@ -504,15 +504,18 @@ configure_openmp_makevars <- function(verbose = TRUE) { # Create ~/.R directory if it doesn't exist r_dir <- base::path.expand("~/.R") if (!base::dir.exists(r_dir)) { - base::tryCatch( - base::dir.create(r_dir, recursive = TRUE), - error = function(e) { - if (verbose) { - cli::cli_alert_warning("{.pkg macrtools}: Could not create ~/.R directory: {.val {e$message}}") - } - return(FALSE) + dir_created <- base::tryCatch({ + base::dir.create(r_dir, recursive = TRUE) + TRUE + }, error = function(e) { + if (verbose) { + cli::cli_alert_warning("{.pkg macrtools}: Could not create ~/.R directory: {.val {e$message}}") } - ) + FALSE + }) + if (!dir_created) { + return(FALSE) + } } if (verbose) { diff --git a/R/toolchain.R b/R/toolchain.R index a949883..4ba6ab1 100644 --- a/R/toolchain.R +++ b/R/toolchain.R @@ -123,7 +123,7 @@ macos_rtools_install <- function( if(!is_xcode_app_installed()) { if(!is_xcode_cli_installed()) { if (verbose) { - cli::cli_progress_update(pb_id, 0.1) + cli::cli_progress_update(id = pb_id, set = 10) cli::cli_alert_info("{.pkg macrtools}: Need to install Xcode Command Line Tools.") cli::cli_bullets(c( "Source: Apple Software Update", @@ -134,7 +134,7 @@ macos_rtools_install <- function( cli::cli_text("") # Add spacing } - if (verbose) cli::cli_progress_update(pb_id, 0.2) + if (verbose) cli::cli_progress_update(id = pb_id, set = 20) result_xcode <- xcode_cli_install(password = entered_password, verbose = describe_steps) if(!result_xcode) { @@ -146,7 +146,7 @@ macos_rtools_install <- function( )) } - if (verbose) cli::cli_progress_update(pb_id, 0.3) + if (verbose) cli::cli_progress_update(id = pb_id, set = 30) } else { if(describe_steps) { # Get Xcode CLI version information @@ -163,7 +163,7 @@ macos_rtools_install <- function( )) cli::cli_text("") # Add spacing } - if (verbose) cli::cli_progress_update(pb_id, 0.3) + if (verbose) cli::cli_progress_update(id = pb_id, set = 30) } } else { if(describe_steps) { @@ -181,7 +181,7 @@ macos_rtools_install <- function( )) cli::cli_text("") # Add spacing } - if (verbose) cli::cli_progress_update(pb_id, 0.3) + if (verbose) cli::cli_progress_update(id = pb_id, set = 30) } # COMPONENT 2: GNU Fortran Compiler @@ -195,7 +195,7 @@ macos_rtools_install <- function( cli::cli_text("") # Add spacing # Step 2: gfortran - if (verbose) cli::cli_progress_update(pb_id, 0.4) + if (verbose) cli::cli_progress_update(id = pb_id, set = 40) result_gfortran <- TRUE if(!is_gfortran_installed()) { if (verbose) { @@ -210,7 +210,7 @@ macos_rtools_install <- function( cli::cli_text("") # Add spacing } - if (verbose) cli::cli_progress_update(pb_id, 0.5) + if (verbose) cli::cli_progress_update(id = pb_id, set = 50) result_gfortran <- gfortran_install(password = entered_password, verbose = describe_steps) if(!result_gfortran) { cli::cli_abort(c( @@ -221,7 +221,7 @@ macos_rtools_install <- function( )) } - if (verbose) cli::cli_progress_update(pb_id, 0.6) + if (verbose) cli::cli_progress_update(id = pb_id, set = 60) } else { if(describe_steps) { # Get gfortran version information @@ -240,7 +240,7 @@ macos_rtools_install <- function( )) cli::cli_text("") # Add spacing } - if (verbose) cli::cli_progress_update(pb_id, 0.6) + if (verbose) cli::cli_progress_update(id = pb_id, set = 60) } # COMPONENT 3: R Development Libraries @@ -255,7 +255,7 @@ macos_rtools_install <- function( # Step 3: r-base-dev if (verbose) { - cli::cli_progress_update(pb_id, 0.7) + cli::cli_progress_update(id = pb_id, set = 70) cli::cli_alert_info("{.pkg macrtools}: Installing R development libraries.") cli::cli_bullets(c( "Source: R-Project macOS binary repository", @@ -273,7 +273,7 @@ macos_rtools_install <- function( # Finalize installation if (verbose) { - cli::cli_progress_update(pb_id, 1.0) + cli::cli_progress_update(id = pb_id, set = 100) cli::cli_progress_done(pb_id) } @@ -355,7 +355,7 @@ macos_rtools_uninstall <- function( result_xcode <- TRUE if(is_xcode_cli_installed()) { if (verbose) { - cli::cli_progress_update(pb_id, 0.3) + cli::cli_progress_update(id = pb_id, set = 30) cli::cli_alert_info("{.pkg macrtools}: Uninstalling Xcode CLI...") cli::cli_text("") # Add spacing } @@ -371,7 +371,7 @@ macos_rtools_uninstall <- function( } # Step 2: Uninstall gfortran - if (verbose) cli::cli_progress_update(pb_id, 0.7) + if (verbose) cli::cli_progress_update(id = pb_id, set = 70) result_gfortran <- TRUE if(is_gfortran_installed()) { if (verbose) { @@ -390,7 +390,7 @@ macos_rtools_uninstall <- function( } if (verbose) { - cli::cli_progress_update(pb_id, 1.0) + cli::cli_progress_update(id = pb_id, set = 100) cli::cli_progress_done(pb_id) } diff --git a/R/xcode.R b/R/xcode.R index 47ba25e..17c9615 100644 --- a/R/xcode.R +++ b/R/xcode.R @@ -240,16 +240,14 @@ xcode_cli_install <- function(password = base::getOption("macrtools.password"), tr -d '\n'", intern = TRUE) if (base::length(product_information) == 0) { + # Remove temporary in-progress file if left in place before aborting. + if(base::file.exists(temporary_xcli_file)) { + base::file.remove(temporary_xcli_file) + } cli::cli_abort(c( "{.pkg macrtools}: Could not find Xcode CLI in software updates.", "i" = "Try installing manually with 'xcode-select --install' in Terminal." )) - - # Remove temporary in-progress file if left in place - if(base::file.exists(temporary_xcli_file)) { - base::file.remove(temporary_xcli_file) - } - return(base::invisible(FALSE)) } if (verbose) { diff --git a/tests/testthat/test-gfortran.R b/tests/testthat/test-gfortran.R index 2767fe9..58680d8 100644 --- a/tests/testthat/test-gfortran.R +++ b/tests/testthat/test-gfortran.R @@ -162,3 +162,13 @@ test_that("gfortran_uninstall removes installed gfortran", { result <- gfortran_uninstall(verbose = TRUE) expect_true(result) }) + +test_that("gfortran() surfaces the error message when the binary is missing", { + # Regression test: the error handler must carry the message in $stdout so the + # caller (which reads out$stdout) does not silently drop it. + mockery::stub(gfortran, "sys::exec_internal", function(...) stop("gfortran not found")) + + res <- gfortran("--version") + expect_equal(res$status, -127L) + expect_match(paste(res$output, collapse = " "), "gfortran not found") +}) diff --git a/tests/testthat/test-installers.R b/tests/testthat/test-installers.R index 3a4e018..e99e4a4 100644 --- a/tests/testthat/test-installers.R +++ b/tests/testthat/test-installers.R @@ -149,3 +149,19 @@ test_that("gfortran_install_location follows the toolchain tiers", { mockery::stub(gfortran_install_location, "is_r_version_supported", function(...) FALSE) expect_error(gfortran_install_location("arm64"), regexp = "Unsupported R version") }) + +test_that("tar_package_install aborts on a positive (non-zero) exit status", { + # A real tar failure exits with a positive status; it must be treated as a + # failure, not silently ignored (regression test for status < 0 vs != 0). + mockery::stub(tar_package_install, "base::shQuote", function(x) paste0("'", x, "'")) + mockery::stub(tar_package_install, "base::normalizePath", function(path) path) + mockery::stub(tar_package_install, "base::basename", function(path) "test.tar.gz") + mockery::stub(tar_package_install, "cli::cli_alert_info", function(...) NULL) + mockery::stub(tar_package_install, "cli::cli_bullets", function(...) NULL) + mockery::stub(tar_package_install, "cli::cli_text", function(...) NULL) + mockery::stub(tar_package_install, "shell_execute", function(...) 1) + mockery::stub(tar_package_install, "cli::cli_abort", function(...) stop("Installation failed")) + + expect_error(tar_package_install("/tmp/test.tar.gz", "/opt", 2, verbose = TRUE), + "Installation failed") +}) From 5796856d270ca0a835f4f24fa9c8140cd90de4dc Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Mon, 22 Jun 2026 22:12:42 -0500 Subject: [PATCH 2/6] Remove dead code after cli_abort Drop 14 unreachable return() statements that followed cli::cli_abort() (which always throws) across gfortran.R, xcode.R, and openmp.R. Also make block_replace() actually print the block it tells the user to copy instead of discarding the paste0() result. --- R/blocks.R | 2 +- R/gfortran.R | 7 ------- R/openmp.R | 3 --- R/xcode.R | 4 ---- 4 files changed, 1 insertion(+), 15 deletions(-) diff --git a/R/blocks.R b/R/blocks.R index 78dde43..54765f7 100644 --- a/R/blocks.R +++ b/R/blocks.R @@ -108,7 +108,7 @@ block_replace = function(desc, value, path, if (base::is.null(block_lines)) { base::message("Copy and paste the following lines into ", path, ":") - base::paste0(base::c(block_start, value, block_end), collapse = "\n") + base::message(base::paste0(base::c(block_start, value, block_end), collapse = "\n")) return(base::invisible(FALSE)) } diff --git a/R/gfortran.R b/R/gfortran.R index da8f401..11327f8 100644 --- a/R/gfortran.R +++ b/R/gfortran.R @@ -281,7 +281,6 @@ gfortran_install <- function(password = base::getOption("macrtools.password"), v "i" = "Please upgrade R to version 4.1 or higher.", "i" = "Visit https://cran.r-project.org/bin/macosx/ to download a compatible R version for Apple Silicon." )) - return(base::invisible(FALSE)) } } else { arch <- system_arch() @@ -289,7 +288,6 @@ gfortran_install <- function(password = base::getOption("macrtools.password"), v "{.pkg macrtools}: Unsupported macOS architecture: {.val {arch}}", "i" = "Only Intel (x86_64) and Apple Silicon (arm64/aarch64) architectures are supported." )) - return(base::invisible(FALSE)) } } @@ -305,7 +303,6 @@ gfortran_install <- function(password = base::getOption("macrtools.password"), v "i" = "R version: {.val {r_version}}", "i" = "Please try to manually install following the instructions at: https://mac.thecoatlessprofessor.com/macrtools/reference/gfortran.html#installing-gfortran" )) - return(base::invisible(FALSE)) } renviron_gfortran_path(path_variable) @@ -392,7 +389,6 @@ gfortran_uninstall <- function(password = base::getOption("macrtools.password"), "{.pkg macrtools}: We were not able to uninstall gfortran.", "i" = "Please try to manually uninstall using: https://mac.thecoatlessprofessor.com/macrtools/reference/gfortran.html#uninstalling-gfortran" )) - return(base::invisible(FALSE)) } if (verbose) { @@ -434,7 +430,6 @@ gfortran_update <- function(password = base::getOption("macrtools.password"), ve # Verify that the tool exists if (!base::file.exists(path_gfortran_update)) { cli::cli_abort("{.pkg macrtools}: Could not find gfortran-update-sdk at {.path {path_gfortran_update}}") - return(base::invisible(FALSE)) } if (verbose) { @@ -458,7 +453,6 @@ gfortran_update <- function(password = base::getOption("macrtools.password"), ve "{.pkg macrtools}: We were not able to update gfortran.", "i" = "Please try to manually update using: https://mac.thecoatlessprofessor.com/macrtools/reference/gfortran.html#updating-gfortran" )) - return(base::invisible(FALSE)) } if (verbose) { @@ -520,7 +514,6 @@ install_gfortran_82_mojave <- function(password = base::getOption("macrtools.pas if (!success) { cli::cli_abort("{.pkg macrtools}: Error installing the gfortran package") - return(FALSE) } # Remove unused gfortran file diff --git a/R/openmp.R b/R/openmp.R index ef1f6c5..00019be 100644 --- a/R/openmp.R +++ b/R/openmp.R @@ -166,7 +166,6 @@ openmp_install <- function(password = base::getOption("macrtools.password"), ver "Xcode version: {.val {xcode_version}}", "i" = "Please check the package documentation for manual installation instructions." )) - return(base::invisible(FALSE)) } if (verbose) { @@ -190,7 +189,6 @@ openmp_install <- function(password = base::getOption("macrtools.password"), ver "{.pkg macrtools}: Failed to install OpenMP.", "i" = "Please check the package documentation for manual installation instructions." )) - return(base::invisible(FALSE)) } # Automatically configure Makevars if requested @@ -288,7 +286,6 @@ openmp_uninstall <- function(password = base::getOption("macrtools.password"), "{.pkg macrtools}: We were not able to uninstall OpenMP.", "i" = "Please check the package documentation for manual uninstall instructions." )) - return(base::invisible(FALSE)) } } diff --git a/R/xcode.R b/R/xcode.R index 17c9615..639b35e 100644 --- a/R/xcode.R +++ b/R/xcode.R @@ -276,7 +276,6 @@ xcode_cli_install <- function(password = base::getOption("macrtools.password"), "{.pkg macrtools}: We were not able to install Xcode CLI.", "i" = "Please try to manually install using: https://mac.thecoatlessprofessor.com/macrtools/reference/xcode-cli.html#xcode-cli-installation" )) - return(base::invisible(FALSE)) } if (verbose) { @@ -361,7 +360,6 @@ xcode_cli_uninstall <- function(password = base::getOption("macrtools.password") "{.pkg macrtools}: We were not able to uninstall Xcode CLI.", "i" = "Please try to manually uninstall using: https://mac.thecoatlessprofessor.com/macrtools/reference/xcode-cli.html#uninstalling-xcode-cli" )) - return(base::invisible(FALSE)) } if (verbose) { @@ -421,7 +419,6 @@ xcode_cli_switch <- function(password = base::getOption("macrtools.password"), v if(base::isFALSE(xcli_switch_clean)) { cli::cli_abort("{.pkg macrtools}: Failed to switch Xcode CLI path.") - return(base::invisible(FALSE)) } if (verbose) { @@ -462,7 +459,6 @@ xcode_cli_reset <- function(password = base::getOption("macrtools.password"), ve if(base::isFALSE(xcli_reset_clean)) { cli::cli_abort("{.pkg macrtools}: Failed to reset Xcode CLI settings.") - return(base::invisible(FALSE)) } if(verbose) { From 2b2f6a2dcb3d1f974b582d833adcbcd44eb2de04 Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Mon, 22 Jun 2026 22:18:31 -0500 Subject: [PATCH 3/6] Extract shared helpers to remove duplication - Add r_version_full() and route the 6 inline paste(R.version...) call sites through it (and is_r_version's full-version branch). - Use system_os() instead of re-inlining tolower(Sys.info()[['sysname']]) in assert_mac() and .onAttach(). - Add arch_switch() for the arm64/aarch64/x86_64 dispatch + shared 'not recognized' error, collapsing four duplicated switch() blocks in installers.R. - Add assert_supported_r_version_for() for the unsupported-R-version guard duplicated across three installer helpers. --- R/assertions.R | 4 +-- R/gfortran.R | 6 ++-- R/installers.R | 60 +++++++++++++------------------- R/system.R | 10 +++++- R/toolchain.R | 2 +- R/zzz.R | 2 +- tests/testthat/test-assertions.R | 2 +- tests/testthat/test-installers.R | 12 +++---- 8 files changed, 48 insertions(+), 50 deletions(-) diff --git a/R/assertions.R b/R/assertions.R index eb535f7..83da8a0 100644 --- a/R/assertions.R +++ b/R/assertions.R @@ -22,7 +22,7 @@ assert <- function(condition, message = NULL, call = caller_env()) { #' @export assert_mac <- function(call = caller_env()) { if (!is_macos()) { - current_os <- base::tolower(base::Sys.info()[['sysname']]) + current_os <- system_os() cli::cli_abort(c( "{.pkg macrtools}: This function requires macOS.", "{.pkg macrtools}: The current operating system is {.val {current_os}}." @@ -80,7 +80,7 @@ assert_x86_64 <- function(call = caller_env()) { #' @export assert_r_version_supported <- function(call = caller_env()) { if (!is_r_version_supported()) { - version_number <- base::paste(base::R.version$major, base::R.version$minor, sep = ".") + version_number <- r_version_full() supported_min <- minimum_supported_r_version() supported_max <- maximum_supported_r_version() cli::cli_abort(c( diff --git a/R/gfortran.R b/R/gfortran.R index 11327f8..ff3abd8 100644 --- a/R/gfortran.R +++ b/R/gfortran.R @@ -205,7 +205,7 @@ gfortran_install <- function(password = base::getOption("macrtools.password"), v if (verbose) { # Get system info arch_info <- system_arch() - r_version <- base::paste(base::R.version$major, base::R.version$minor, sep='.') + r_version <- r_version_full() cli::cli_alert_info("{.pkg macrtools}: Preparing to download and install gfortran.") cli::cli_bullets(c( @@ -294,7 +294,7 @@ gfortran_install <- function(password = base::getOption("macrtools.password"), v if(base::isFALSE(gfortran_status)) { install_path <- gfortran_install_location() arch_info <- system_arch() - r_version <- base::paste(base::R.version$major, base::R.version$minor, sep='.') + r_version <- r_version_full() cli::cli_abort(c( "{.pkg macrtools}: Failed to install gfortran.", @@ -437,7 +437,7 @@ gfortran_update <- function(password = base::getOption("macrtools.password"), ve cli::cli_bullets(c( "Update tool: {.path {path_gfortran_update}}", "Architecture: {.val {system_arch()}}", - "R version: {.val {base::paste(base::R.version$major, base::R.version$minor, sep='.')}}" + "R version: {.val {r_version_full()}}" )) cli::cli_text("") # Add spacing } diff --git a/R/installers.R b/R/installers.R index d8bf2bc..43c2809 100644 --- a/R/installers.R +++ b/R/installers.R @@ -1,57 +1,51 @@ # Installation directories ---- -install_strip_level <- function(arch = system_arch()) { +# Dispatch on architecture, mapping arm64/aarch64 to the same value, with a +# shared error for anything that is not Apple Silicon or Intel. +arch_switch <- function(arch, arm64, x86_64) { base::switch( arch, - "arm64" = 3, - "aarch64" = 3, - "x86_64" = 2, + "arm64" = arm64, + "aarch64" = arm64, + "x86_64" = x86_64, cli::cli_abort("{.pkg macrtools}: Architecture {.val {arch}} not recognized. Please ensure you are on either an Apple Silicon (arm64) or Intel (x86_64) system.") ) } -recipe_binary_install_strip_level <- function(arch = system_arch()) { +# Guard the recipe/gfortran installers against unsupported R versions, naming +# the component that is unavailable. +assert_supported_r_version_for <- function(component) { if (!is_r_version_supported()) { supported_min <- minimum_supported_r_version() supported_max <- maximum_supported_r_version() - cli::cli_abort("{.pkg macrtools}: Unsupported R version. We only support recipe binary installation for R {supported_min}.x through R {supported_max}.x.") + cli::cli_abort("{.pkg macrtools}: Unsupported R version. We only support {component} for R {supported_min}.x through R {supported_max}.x.") } +} + +install_strip_level <- function(arch = system_arch()) { + arch_switch(arch, arm64 = 3, x86_64 = 2) +} + +recipe_binary_install_strip_level <- function(arch = system_arch()) { + assert_supported_r_version_for("recipe binary installation") if (is_r_version_at_least("4.3")) { - base::switch( - arch, - "arm64" = 3, - "aarch64" = 3, - "x86_64" = 3, - cli::cli_abort("{.pkg macrtools}: Architecture {.val {arch}} not recognized. Please ensure you are on either an Apple Silicon (arm64) or Intel (x86_64) system.") - ) + arch_switch(arch, arm64 = 3, x86_64 = 3) } else { install_strip_level() } } install_location <- function(arch = system_arch()) { - base::switch( - arch, - "arm64" = install_directory_arm64(), - "aarch64" = install_directory_arm64(), - "x86_64" = install_directory_x86_64(), - cli::cli_abort("{.pkg macrtools}: Architecture {.val {arch}} not recognized. Please ensure you are on either an Apple Silicon (arm64) or Intel (x86_64) system.") - ) + arch_switch(arch, arm64 = install_directory_arm64(), x86_64 = install_directory_x86_64()) } recipe_binary_install_location <- function(arch = system_arch()) { - if (!is_r_version_supported()) { - supported_min <- minimum_supported_r_version() - supported_max <- maximum_supported_r_version() - cli::cli_abort("{.pkg macrtools}: Unsupported R version. We only support recipe binary installation for R {supported_min}.x through R {supported_max}.x.") - } + assert_supported_r_version_for("recipe binary installation") if (is_r_version_at_least("4.3")) { - base::switch( + arch_switch( arch, - "arm64" = install_directory_arm64(), - "aarch64" = install_directory_arm64(), - "x86_64" = install_directory_x86_64_r_version_4_3(), - cli::cli_abort("{.pkg macrtools}: Architecture {.val {arch}} not recognized. Please ensure you are on either an Apple Silicon (arm64) or Intel (x86_64) system.") + arm64 = install_directory_arm64(), + x86_64 = install_directory_x86_64_r_version_4_3() ) } else { install_location() @@ -59,11 +53,7 @@ recipe_binary_install_location <- function(arch = system_arch()) { } gfortran_install_location <- function(arch = system_arch()) { - if (!is_r_version_supported()) { - supported_min <- minimum_supported_r_version() - supported_max <- maximum_supported_r_version() - cli::cli_abort("{.pkg macrtools}: Unsupported R version. We only support gfortran installation for R {supported_min}.x through R {supported_max}.x.") - } + assert_supported_r_version_for("gfortran installation") if (is_r_version_at_least("4.3")) { "/opt" } else { diff --git a/R/system.R b/R/system.R index 539b8a6..a42919d 100644 --- a/R/system.R +++ b/R/system.R @@ -186,6 +186,14 @@ r_version_major_minor <- function() { base::paste(base::R.version$major, minor_value, sep = ".") } +#' Running R Version as a Full major.minor.patch String +#' +#' @return The running R version as a `"major.minor.patch"` string, e.g. `"4.6.0"`. +#' @keywords internal +r_version_full <- function() { + base::paste(base::R.version$major, base::R.version$minor, sep = ".") +} + #' Supported R Version Window #' #' Single source of truth for the oldest and newest R minor versions whose @@ -241,7 +249,7 @@ is_r_version <- function(target_version, compare_major_minor = TRUE) { r_version_major_minor() } else { # If x.y.z, this compares against the full x.y.z - base::paste(base::R.version$major, base::R.version$minor, sep = ".") + r_version_full() } # Check for equality. diff --git a/R/toolchain.R b/R/toolchain.R index 4ba6ab1..d440e7a 100644 --- a/R/toolchain.R +++ b/R/toolchain.R @@ -67,7 +67,7 @@ macos_rtools_install <- function( os_version <- shell_mac_version() os_release <- base::Sys.info()['release'] arch <- system_arch() - r_version <- base::paste(base::R.version$major, base::R.version$minor, sep='.') + r_version <- r_version_full() cli::cli_alert_info("System requirements check:") cli::cli_bullets(c( diff --git a/R/zzz.R b/R/zzz.R index 212f9c2..b7c4889 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -4,7 +4,7 @@ # Check if it's actually macOS if (!is_macos()) { - os_info <- base::tolower(base::Sys.info()[['sysname']]) + os_info <- system_os() base::packageStartupMessage(cli::format_inline( "{.pkg macrtools}: {.emph Warning: This package is designed for macOS only.}", "\n{.pkg macrtools}: {.emph Current OS: {.val {os_info}}}", diff --git a/tests/testthat/test-assertions.R b/tests/testthat/test-assertions.R index 3616bf9..7f58146 100644 --- a/tests/testthat/test-assertions.R +++ b/tests/testthat/test-assertions.R @@ -13,7 +13,7 @@ test_that("assert_mac succeeds on macOS", { test_that("assert_mac throws error on non-macOS", { mockery::stub(assert_mac, "is_macos", function() FALSE) - mockery::stub(assert_mac, "base::Sys.info", function() c(sysname = "Linux")) + mockery::stub(assert_mac, "system_os", function() "linux") expect_error(assert_mac(), regexp = "This function requires macOS") }) diff --git a/tests/testthat/test-installers.R b/tests/testthat/test-installers.R index e99e4a4..ef023c7 100644 --- a/tests/testthat/test-installers.R +++ b/tests/testthat/test-installers.R @@ -100,7 +100,7 @@ test_that("pkg_install handles successful installation", { test_that("recipe_binary_install_strip_level follows the toolchain tiers", { # Modern tier (R >= 4.3): strip 3 for every architecture - mockery::stub(recipe_binary_install_strip_level, "is_r_version_supported", function(...) TRUE) + mockery::stub(recipe_binary_install_strip_level, "assert_supported_r_version_for", function(...) invisible(NULL)) mockery::stub(recipe_binary_install_strip_level, "is_r_version_at_least", function(...) TRUE) expect_equal(recipe_binary_install_strip_level("arm64"), 3) expect_equal(recipe_binary_install_strip_level("aarch64"), 3) @@ -112,13 +112,13 @@ test_that("recipe_binary_install_strip_level follows the toolchain tiers", { expect_equal(recipe_binary_install_strip_level("arm64"), 99) # Unsupported R version: abort - mockery::stub(recipe_binary_install_strip_level, "is_r_version_supported", function(...) FALSE) + mockery::stub(recipe_binary_install_strip_level, "assert_supported_r_version_for", function(...) stop("Unsupported R version")) expect_error(recipe_binary_install_strip_level("arm64"), regexp = "Unsupported R version") }) test_that("recipe_binary_install_location follows the toolchain tiers", { # Modern tier (R >= 4.3): /opt/R/ - mockery::stub(recipe_binary_install_location, "is_r_version_supported", function(...) TRUE) + mockery::stub(recipe_binary_install_location, "assert_supported_r_version_for", function(...) invisible(NULL)) mockery::stub(recipe_binary_install_location, "is_r_version_at_least", function(...) TRUE) expect_equal(recipe_binary_install_location("arm64"), "/opt/R/arm64") expect_equal(recipe_binary_install_location("aarch64"), "/opt/R/arm64") @@ -130,13 +130,13 @@ test_that("recipe_binary_install_location follows the toolchain tiers", { expect_equal(recipe_binary_install_location("x86_64"), "/legacy/loc") # Unsupported R version: abort - mockery::stub(recipe_binary_install_location, "is_r_version_supported", function(...) FALSE) + mockery::stub(recipe_binary_install_location, "assert_supported_r_version_for", function(...) stop("Unsupported R version")) expect_error(recipe_binary_install_location("arm64"), regexp = "Unsupported R version") }) test_that("gfortran_install_location follows the toolchain tiers", { # Modern tier (R >= 4.3): /opt - mockery::stub(gfortran_install_location, "is_r_version_supported", function(...) TRUE) + mockery::stub(gfortran_install_location, "assert_supported_r_version_for", function(...) invisible(NULL)) mockery::stub(gfortran_install_location, "is_r_version_at_least", function(...) TRUE) expect_equal(gfortran_install_location("arm64"), "/opt") @@ -146,7 +146,7 @@ test_that("gfortran_install_location follows the toolchain tiers", { expect_equal(gfortran_install_location("x86_64"), "/legacy/loc") # Unsupported R version: abort - mockery::stub(gfortran_install_location, "is_r_version_supported", function(...) FALSE) + mockery::stub(gfortran_install_location, "assert_supported_r_version_for", function(...) stop("Unsupported R version")) expect_error(gfortran_install_location("arm64"), regexp = "Unsupported R version") }) From b6b5c21df93fe7f810c17db3e1752f4abc9bc7c6 Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Mon, 22 Jun 2026 23:52:04 -0500 Subject: [PATCH 4/6] Split macos_rtools_install and add focused tests - Break the 265-line macos_rtools_install() into rtools_install_announce/xcode_cli/gfortran/recipes/summary helpers; the orchestrator is now a short, readable sequence with the same behavior and output. - Extract the softwareupdate label pipeline in xcode_cli_install() into xcode_cli_available_label(). - Rewrite the toolchain tests to target the new step seams instead of mocking base-R internals, and add test-openmp.R (OpenMP was previously untested). --- R/toolchain.R | 106 ++++++++++++++--------- R/xcode.R | 21 +++-- tests/testthat/test-openmp.R | 43 ++++++++++ tests/testthat/test-toolchain.R | 146 +++++++++++++++++--------------- 4 files changed, 199 insertions(+), 117 deletions(-) create mode 100644 tests/testthat/test-openmp.R diff --git a/R/toolchain.R b/R/toolchain.R index d440e7a..2cfb9c3 100644 --- a/R/toolchain.R +++ b/R/toolchain.R @@ -53,7 +53,51 @@ macos_rtools_install <- function( assert_macos_supported() assert_r_version_supported() - # Installation heading and components list - proper formatting + # System information shared across the announcement and the install steps + os_version <- shell_mac_version() + os_release <- base::Sys.info()['release'] + arch <- system_arch() + r_version <- r_version_full() + + rtools_install_announce(os_version, os_release, arch, r_version) + + entered_password <- password + if(base::is.null(entered_password)) { + cli::cli_alert_info("Administrative privileges are required for installation.") + entered_password <- askpass::askpass("Please enter your administrator password:") + } + + describe_steps <- base::isTRUE(verbose) + + # Create a detailed progress bar (pb_id stays NULL when not verbose) + pb_id <- NULL + if (verbose) { + pb_id <- cli::cli_progress_bar( + format = "{.pkg macrtools}: {.strong Installing R development toolchain} {cli::pb_bar} {cli::pb_percent} {cli::pb_spin}", + format_done = "{.pkg macrtools}: {.strong Installation complete} {cli::symbol$tick}", + total = 100 + ) + + current_time <- base::format(base::Sys.time(), '%Y-%m-%d %H:%M:%S') + cli::cli_alert_info("Installation process started at: {.val {current_time}}") + cli::cli_text("") # Add spacing + } + + result_xcode <- rtools_install_xcode_cli(entered_password, verbose, describe_steps, pb_id) + result_gfortran <- rtools_install_gfortran(entered_password, verbose, describe_steps, pb_id, arch, r_version) + result_base_dev <- rtools_install_recipes(entered_password, verbose, pb_id, arch) + + # Finalize installation + if (verbose) { + cli::cli_progress_update(id = pb_id, set = 100) + cli::cli_progress_done(pb_id) + } + + base::invisible(rtools_install_summary(result_xcode, result_gfortran, result_base_dev)) +} + +# Announce the toolchain install: heading, system requirements, and prerequisites. +rtools_install_announce <- function(os_version, os_release, arch, r_version) { cli::cli_h3("Installing macOS R Development Toolchain") cli::cli_text("This process will install all required components:") cli::cli_ul(c( @@ -63,12 +107,6 @@ macos_rtools_install <- function( )) cli::cli_text("") # Add spacing - # System information with temporary variables - os_version <- shell_mac_version() - os_release <- base::Sys.info()['release'] - arch <- system_arch() - r_version <- r_version_full() - cli::cli_alert_info("System requirements check:") cli::cli_bullets(c( "Operating system: {.val {os_version}} ({.val {os_release}})", @@ -86,29 +124,10 @@ macos_rtools_install <- function( "Required disk space: Approximately 6-9 GB" )) cli::cli_text("") # Add spacing +} - entered_password <- password - if(base::is.null(entered_password)) { - cli::cli_alert_info("Administrative privileges are required for installation.") - entered_password <- askpass::askpass("Please enter your administrator password:") - } - - describe_steps <- base::isTRUE(verbose) - - # Create a detailed progress bar - if (verbose) { - pb_id <- cli::cli_progress_bar( - format = "{.pkg macrtools}: {.strong Installing R development toolchain} {cli::pb_bar} {cli::pb_percent} {cli::pb_spin}", - format_done = "{.pkg macrtools}: {.strong Installation complete} {cli::symbol$tick}", - total = 100 - ) - - current_time <- base::format(base::Sys.time(), '%Y-%m-%d %H:%M:%S') - cli::cli_alert_info("Installation process started at: {.val {current_time}}") - cli::cli_text("") # Add spacing - } - - # COMPONENT 1: Xcode Command Line Tools +# COMPONENT 1: Xcode Command Line Tools. Returns TRUE if installed/available. +rtools_install_xcode_cli <- function(entered_password, verbose, describe_steps, pb_id) { cli::cli_h3("Component 1 of 3: Xcode Command Line Tools") cli::cli_text("Apple's development utilities providing compilers and build tools") cli::cli_ul(c( @@ -118,7 +137,6 @@ macos_rtools_install <- function( )) cli::cli_text("") # Add spacing - # Step 1: Xcode CLI result_xcode <- TRUE if(!is_xcode_app_installed()) { if(!is_xcode_cli_installed()) { @@ -184,7 +202,11 @@ macos_rtools_install <- function( if (verbose) cli::cli_progress_update(id = pb_id, set = 30) } - # COMPONENT 2: GNU Fortran Compiler + result_xcode +} + +# COMPONENT 2: GNU Fortran compiler. Returns TRUE if installed/available. +rtools_install_gfortran <- function(entered_password, verbose, describe_steps, pb_id, arch, r_version) { cli::cli_h3("Component 2 of 3: GNU Fortran Compiler") cli::cli_text("Essential compiler for scientific computing and many CRAN packages") cli::cli_ul(c( @@ -194,7 +216,6 @@ macos_rtools_install <- function( )) cli::cli_text("") # Add spacing - # Step 2: gfortran if (verbose) cli::cli_progress_update(id = pb_id, set = 40) result_gfortran <- TRUE if(!is_gfortran_installed()) { @@ -243,7 +264,11 @@ macos_rtools_install <- function( if (verbose) cli::cli_progress_update(id = pb_id, set = 60) } - # COMPONENT 3: R Development Libraries + result_gfortran +} + +# COMPONENT 3: R development libraries via recipes. Returns the install result. +rtools_install_recipes <- function(entered_password, verbose, pb_id, arch) { cli::cli_h3("Component 3 of 3: R Development Libraries") cli::cli_text("Essential third-party libraries required for R package development") cli::cli_ul(c( @@ -253,7 +278,6 @@ macos_rtools_install <- function( )) cli::cli_text("") # Add spacing - # Step 3: r-base-dev if (verbose) { cli::cli_progress_update(id = pb_id, set = 70) cli::cli_alert_info("{.pkg macrtools}: Installing R development libraries.") @@ -267,16 +291,14 @@ macos_rtools_install <- function( cli::cli_text("") # Add spacing } - result_base_dev <- recipes_binary_install( + recipes_binary_install( "r-base-dev", sudo = TRUE, password = entered_password, verbose = verbose ) +} - # Finalize installation - if (verbose) { - cli::cli_progress_update(id = pb_id, set = 100) - cli::cli_progress_done(pb_id) - } - +# Print the success summary or abort describing which components failed. +# Returns TRUE when every component installed cleanly. +rtools_install_summary <- function(result_xcode, result_gfortran, result_base_dev) { rtools_install_clean <- result_gfortran && result_xcode && base::isTRUE(result_base_dev) if(rtools_install_clean) { @@ -309,7 +331,7 @@ macos_rtools_install <- function( )) } - base::invisible(rtools_install_clean) + rtools_install_clean } diff --git a/R/xcode.R b/R/xcode.R index 639b35e..c07d025 100644 --- a/R/xcode.R +++ b/R/xcode.R @@ -230,14 +230,7 @@ xcode_cli_install <- function(password = base::getOption("macrtools.password"), cli::cli_text("") # Add spacing } - product_information <- - base::system("softwareupdate -l | - grep '\\*.*Command Line' | - tail -n 1 | - awk -F\"*\" '{print $2}' | - sed -e 's/^ *//' | - sed 's/Label: //g' | - tr -d '\n'", intern = TRUE) + product_information <- xcode_cli_available_label() if (base::length(product_information) == 0) { # Remove temporary in-progress file if left in place before aborting. @@ -286,6 +279,18 @@ xcode_cli_install <- function(password = base::getOption("macrtools.password"), return(base::invisible(xcli_clean)) } +# Query softwareupdate for the label of the latest available Command Line Tools. +# Returns a length-0 character vector when none is found. +xcode_cli_available_label <- function() { + base::system("softwareupdate -l | + grep '\\*.*Command Line' | + tail -n 1 | + awk -F\"*\" '{print $2}' | + sed -e 's/^ *//' | + sed 's/Label: //g' | + tr -d '\n'", intern = TRUE) +} + #' @section Uninstalling Xcode CLI: #' diff --git a/tests/testthat/test-openmp.R b/tests/testthat/test-openmp.R new file mode 100644 index 0000000..7abce36 --- /dev/null +++ b/tests/testthat/test-openmp.R @@ -0,0 +1,43 @@ +test_that("is_openmp_installed checks for the library and header", { + mockery::stub(is_openmp_installed, "assert_mac", function() NULL) + + mockery::stub(is_openmp_installed, "base::file.exists", function(p) TRUE) + expect_true(is_openmp_installed()) + + mockery::stub(is_openmp_installed, "base::file.exists", function(p) FALSE) + expect_false(is_openmp_installed()) +}) + +test_that("get_openmp_url_for_xcode maps Apple clang build numbers to runtimes", { + pick <- function(build) { + mockery::stub(get_openmp_url_for_xcode, "get_apple_clang_version", + function() list(build_number = build)) + get_openmp_url_for_xcode() + } + + # Xcode 16.3+ / Xcode 26 (clang 1700.x) -> newest runtime + expect_match(pick(1700), "openmp-19.1.5-darwin20-Release.tar.gz") + # Xcode 16.0-16.2 (clang 1600.x) + expect_match(pick(1600), "openmp-17.0.6-darwin20-Release.tar.gz") + # Xcode 14.0-14.2 (clang 1400.x) + expect_match(pick(1400), "openmp-14.0.6-darwin20-Release.tar.gz") + # A build newer than anything mapped still resolves to the newest runtime + expect_match(pick(9999), "openmp-19.1.5-darwin20-Release.tar.gz") + # Undetectable clang (build 0) falls back to the newest runtime + expect_match(pick(0), "openmp-19.1.5-darwin20-Release.tar.gz") +}) + +test_that("openmp_uninstall honors configure_makevars without erroring", { + # Regression: openmp_uninstall() previously referenced an undefined + # `remove_makevars_config`, erroring after removing the libraries. + mockery::stub(openmp_uninstall, "assert_mac", function() NULL) + mockery::stub(openmp_uninstall, "is_openmp_installed", function() TRUE) + mockery::stub(openmp_uninstall, "base::file.exists", function(...) FALSE) + mockery::stub(openmp_uninstall, "remove_openmp_makevars_config", function(...) TRUE) + mockery::stub(openmp_uninstall, "cli::cli_alert_info", function(...) NULL) + mockery::stub(openmp_uninstall, "cli::cli_bullets", function(...) NULL) + mockery::stub(openmp_uninstall, "cli::cli_text", function(...) NULL) + mockery::stub(openmp_uninstall, "cli::cli_alert_success", function(...) NULL) + + expect_true(openmp_uninstall(password = "pw", verbose = TRUE, configure_makevars = TRUE)) +}) diff --git a/tests/testthat/test-toolchain.R b/tests/testthat/test-toolchain.R index 38b7d44..e53c6f8 100644 --- a/tests/testthat/test-toolchain.R +++ b/tests/testthat/test-toolchain.R @@ -16,98 +16,110 @@ test_that("macos_rtools_install performs system checks first", { expect_error(macos_rtools_install(), "Unsupported macOS") }) -test_that("macos_rtools_install handles component installations", { - # Mock all system checks +test_that("macos_rtools_install orchestrates the component steps", { mockery::stub(macos_rtools_install, "assert_mac", function() NULL) mockery::stub(macos_rtools_install, "assert_macos_supported", function() NULL) mockery::stub(macos_rtools_install, "assert_r_version_supported", function() NULL) - - # Mock system info gathering mockery::stub(macos_rtools_install, "shell_mac_version", function() "14.0") mockery::stub(macos_rtools_install, "system_arch", function() "aarch64") mockery::stub(macos_rtools_install, "base::Sys.info", function() c(release = "23.0")) - mockery::stub(macos_rtools_install, "base::R.version", list(major = "4", minor = "3")) - mockery::stub(macos_rtools_install, "base::paste", function(...) "4.3") - mockery::stub(macos_rtools_install, "base::tryCatch", function(...) 10) - mockery::stub(macos_rtools_install, "base::round", function(...) 10) - mockery::stub(macos_rtools_install, "base::as.numeric", function(...) 10) - mockery::stub(macos_rtools_install, "base::format", function(...) "2023-01-01 12:00:00") - mockery::stub(macos_rtools_install, "base::Sys.time", function() Sys.time()) - - # Mock all CLI functions - mockery::stub(macos_rtools_install, "cli::cli_h3", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_text", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_ul", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_alert_info", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_bullets", function(...) NULL) + mockery::stub(macos_rtools_install, "rtools_install_announce", function(...) NULL) + + # The three component steps are the seams; orchestration just wires them up. + mockery::stub(macos_rtools_install, "rtools_install_xcode_cli", function(...) TRUE) + mockery::stub(macos_rtools_install, "rtools_install_gfortran", function(...) TRUE) + mockery::stub(macos_rtools_install, "rtools_install_recipes", function(...) TRUE) + mockery::stub(macos_rtools_install, "rtools_install_summary", + function(x, g, b) x && g && base::isTRUE(b)) + + # Progress-bar scaffolding lives in macos_rtools_install itself mockery::stub(macos_rtools_install, "cli::cli_progress_bar", function(...) 1) mockery::stub(macos_rtools_install, "cli::cli_progress_update", function(...) NULL) mockery::stub(macos_rtools_install, "cli::cli_progress_done", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_alert_success", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_code", function(...) NULL) - - # Mock password entry - mockery::stub(macos_rtools_install, "askpass::askpass", function(...) "password") - - # Mock Xcode installation - mockery::stub(macos_rtools_install, "is_xcode_app_installed", function() FALSE) - mockery::stub(macos_rtools_install, "is_xcode_cli_installed", function() FALSE) - mockery::stub(macos_rtools_install, "xcode_cli_install", function(...) TRUE) - - # Mock gfortran installation - mockery::stub(macos_rtools_install, "is_gfortran_installed", function() FALSE) - mockery::stub(macos_rtools_install, "gfortran_install", function(...) TRUE) - - # Mock recipe installation - mockery::stub(macos_rtools_install, "recipe_binary_install_location", function(...) "/opt/R/arm64") - mockery::stub(macos_rtools_install, "recipes_binary_install", function(...) TRUE) - - # Mock version info for summary - mockery::stub(macos_rtools_install, "base::tryCatch", function(...) "Mock version info") - mockery::stub(macos_rtools_install, "base::substr", function(...) "Mock version") - mockery::stub(macos_rtools_install, "base::paste0", function(...) "Mock version...") - mockery::stub(macos_rtools_install, "base::nchar", function(...) 30) + mockery::stub(macos_rtools_install, "cli::cli_alert_info", function(...) NULL) + mockery::stub(macos_rtools_install, "cli::cli_text", function(...) NULL) - result <- macos_rtools_install(verbose = TRUE) + result <- macos_rtools_install(password = "password", verbose = TRUE) expect_true(result) }) -test_that("macos_rtools_install handles component failures", { - # Mock all system checks +test_that("macos_rtools_install aborts when a component step fails", { mockery::stub(macos_rtools_install, "assert_mac", function() NULL) mockery::stub(macos_rtools_install, "assert_macos_supported", function() NULL) mockery::stub(macos_rtools_install, "assert_r_version_supported", function() NULL) - - # Mock system info gathering mockery::stub(macos_rtools_install, "shell_mac_version", function() "14.0") mockery::stub(macos_rtools_install, "system_arch", function() "aarch64") mockery::stub(macos_rtools_install, "base::Sys.info", function() c(release = "23.0")) - mockery::stub(macos_rtools_install, "base::R.version", list(major = "4", minor = "3")) - mockery::stub(macos_rtools_install, "base::paste", function(...) "4.3") - mockery::stub(macos_rtools_install, "base::tryCatch", function(...) 10) - mockery::stub(macos_rtools_install, "base::round", function(...) 10) - mockery::stub(macos_rtools_install, "base::as.numeric", function(...) 10) + mockery::stub(macos_rtools_install, "rtools_install_announce", function(...) NULL) + + mockery::stub(macos_rtools_install, "rtools_install_xcode_cli", function(...) TRUE) + mockery::stub(macos_rtools_install, "rtools_install_gfortran", function(...) TRUE) + mockery::stub(macos_rtools_install, "rtools_install_recipes", function(...) FALSE) - # Mock all CLI functions - mockery::stub(macos_rtools_install, "cli::cli_h3", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_text", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_ul", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_alert_info", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_bullets", function(...) NULL) mockery::stub(macos_rtools_install, "cli::cli_progress_bar", function(...) 1) mockery::stub(macos_rtools_install, "cli::cli_progress_update", function(...) NULL) mockery::stub(macos_rtools_install, "cli::cli_progress_done", function(...) NULL) - mockery::stub(macos_rtools_install, "cli::cli_abort", function(...) stop("Installation failed")) + mockery::stub(macos_rtools_install, "cli::cli_alert_info", function(...) NULL) + mockery::stub(macos_rtools_install, "cli::cli_text", function(...) NULL) + + # rtools_install_summary runs for real and aborts on the failed component + expect_error(macos_rtools_install(password = "password", verbose = TRUE), + "Installation failed") +}) + +test_that("rtools_install_summary returns TRUE on success and aborts on failure", { + mockery::stub(rtools_install_summary, "cli::cli_h3", function(...) NULL) + mockery::stub(rtools_install_summary, "cli::cli_ul", function(...) NULL) + mockery::stub(rtools_install_summary, "cli::cli_text", function(...) NULL) + mockery::stub(rtools_install_summary, "cli::cli_alert_info", function(...) NULL) + mockery::stub(rtools_install_summary, "cli::cli_alert_success", function(...) NULL) + mockery::stub(rtools_install_summary, "base::format", function(...) "now") + + expect_true(rtools_install_summary(TRUE, TRUE, TRUE)) + expect_error(rtools_install_summary(TRUE, FALSE, TRUE), "Installation failed") +}) + +test_that("rtools_install_xcode_cli installs when missing and aborts on failure", { + mockery::stub(rtools_install_xcode_cli, "cli::cli_h3", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_text", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_ul", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_alert_info", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_bullets", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_progress_update", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "is_xcode_app_installed", function() FALSE) + mockery::stub(rtools_install_xcode_cli, "is_xcode_cli_installed", function() FALSE) + + mockery::stub(rtools_install_xcode_cli, "xcode_cli_install", function(...) TRUE) + expect_true(rtools_install_xcode_cli("pw", FALSE, FALSE, NULL)) + + mockery::stub(rtools_install_xcode_cli, "xcode_cli_install", function(...) FALSE) + expect_error(rtools_install_xcode_cli("pw", FALSE, FALSE, NULL), "Failed to install Xcode") +}) + +test_that("rtools_install_gfortran installs when missing and aborts on failure", { + mockery::stub(rtools_install_gfortran, "cli::cli_h3", function(...) NULL) + mockery::stub(rtools_install_gfortran, "cli::cli_text", function(...) NULL) + mockery::stub(rtools_install_gfortran, "cli::cli_ul", function(...) NULL) + mockery::stub(rtools_install_gfortran, "cli::cli_alert_info", function(...) NULL) + mockery::stub(rtools_install_gfortran, "cli::cli_bullets", function(...) NULL) + mockery::stub(rtools_install_gfortran, "cli::cli_progress_update", function(...) NULL) + mockery::stub(rtools_install_gfortran, "is_gfortran_installed", function() FALSE) + + mockery::stub(rtools_install_gfortran, "gfortran_install", function(...) TRUE) + expect_true(rtools_install_gfortran("pw", FALSE, FALSE, NULL, "aarch64", "4.6.0")) + + mockery::stub(rtools_install_gfortran, "gfortran_install", function(...) FALSE) + expect_error(rtools_install_gfortran("pw", FALSE, FALSE, NULL, "aarch64", "4.6.0"), + "Failed to install GNU Fortran") +}) - # Mock component failures - mockery::stub(macos_rtools_install, "is_xcode_app_installed", function() FALSE) - mockery::stub(macos_rtools_install, "is_xcode_cli_installed", function() FALSE) - mockery::stub(macos_rtools_install, "xcode_cli_install", function(...) FALSE) - mockery::stub(macos_rtools_install, "is_gfortran_installed", function() FALSE) - mockery::stub(macos_rtools_install, "gfortran_install", function(...) FALSE) - mockery::stub(macos_rtools_install, "recipes_binary_install", function(...) FALSE) +test_that("rtools_install_recipes returns the recipes install result", { + mockery::stub(rtools_install_recipes, "cli::cli_h3", function(...) NULL) + mockery::stub(rtools_install_recipes, "cli::cli_text", function(...) NULL) + mockery::stub(rtools_install_recipes, "cli::cli_ul", function(...) NULL) + mockery::stub(rtools_install_recipes, "recipes_binary_install", function(...) TRUE) - expect_error(macos_rtools_install(verbose = TRUE), "Installation failed") + expect_true(rtools_install_recipes("pw", FALSE, NULL, "aarch64")) }) test_that("macos_rtools_uninstall handles component uninstallations", { From 76bbf8bc50e2aa5f9d08dafe7cc650f26c1d455d Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Tue, 23 Jun 2026 06:12:05 -0500 Subject: [PATCH 5/6] Extract legacy branch from gfortran_install Move the R 4.0-4.2 architecture dispatch (Intel Mojave DMG, arm64 gfortran 11/12, and the unsupported-arch/old-R aborts) into install_gfortran_legacy(), flattening gfortran_install() from 142 to ~100 lines. Add a focused test covering each legacy dispatch branch. --- R/gfortran.R | 105 ++++++++++++++++++--------------- tests/testthat/test-gfortran.R | 31 ++++++++-- 2 files changed, 85 insertions(+), 51 deletions(-) diff --git a/R/gfortran.R b/R/gfortran.R index ff3abd8..2564a44 100644 --- a/R/gfortran.R +++ b/R/gfortran.R @@ -243,52 +243,9 @@ gfortran_install <- function(password = base::getOption("macrtools.password"), v password = entered_password_gfortran, verbose = verbose) } else if(is_r_version_at_least("4.0")) { - if (is_x86_64()) { - gfortran_status <- install_gfortran_82_mojave( - password = entered_password_gfortran, - verbose = verbose) - } else if (is_aarch64()) { - if (is_r_version("4.2")) { - if (verbose) { - install_path <- install_location() - cli::cli_alert_info("{.pkg macrtools}: Installing gfortran 12 for Apple Silicon with R 4.2") - cli::cli_bullets(c( - "Source: {.url https://mac.r-project.org/tools/}", - "Target installation: {.path {install_path}}" - )) - cli::cli_text("") # Add spacing - } - gfortran_status <- install_gfortran_12_arm( - password = entered_password_gfortran, - verbose = verbose) - } else if(is_r_version("4.1")) { - if (verbose) { - install_path <- install_location() - cli::cli_alert_info("{.pkg macrtools}: Installing gfortran 11 for Apple Silicon with R 4.1") - cli::cli_bullets(c( - "Source: {.url https://mac.r-project.org/libs-arm64/}", - "Target installation: {.path {install_path}}" - )) - cli::cli_text("") # Add spacing - } - gfortran_status <- install_gfortran_11_arm( - password = entered_password_gfortran, - verbose = verbose) - } else { - cli::cli_abort(c( - "{.pkg macrtools}: Unable to install gfortran for Apple Silicon (arm64/aarch64).", - "i" = "Official R support for Apple Silicon began in R 4.1.", - "i" = "Please upgrade R to version 4.1 or higher.", - "i" = "Visit https://cran.r-project.org/bin/macosx/ to download a compatible R version for Apple Silicon." - )) - } - } else { - arch <- system_arch() - cli::cli_abort(c( - "{.pkg macrtools}: Unsupported macOS architecture: {.val {arch}}", - "i" = "Only Intel (x86_64) and Apple Silicon (arm64/aarch64) architectures are supported." - )) - } + gfortran_status <- install_gfortran_legacy( + password = entered_password_gfortran, + verbose = verbose) } if(base::isFALSE(gfortran_status)) { @@ -483,6 +440,62 @@ gfortran <- function(args) { ) } +#' Install gfortran for the legacy R 4.0-4.2 toolchain +#' +#' @details +#' Intel Macs use the gfortran 8.2 Mojave DMG; Apple Silicon uses the gfortran 12 +#' (R 4.2) or gfortran 11 (R 4.1) arm64 tarball. Apple Silicon on R 4.0 and any +#' other architecture are unsupported and abort. +#' +#' @noRd +install_gfortran_legacy <- function(password = base::getOption("macrtools.password"), + verbose = TRUE) { + if (is_x86_64()) { + return(install_gfortran_82_mojave(password = password, verbose = verbose)) + } + + if (!is_aarch64()) { + arch <- system_arch() + cli::cli_abort(c( + "{.pkg macrtools}: Unsupported macOS architecture: {.val {arch}}", + "i" = "Only Intel (x86_64) and Apple Silicon (arm64/aarch64) architectures are supported." + )) + } + + if (is_r_version("4.2")) { + if (verbose) { + install_path <- install_location() + cli::cli_alert_info("{.pkg macrtools}: Installing gfortran 12 for Apple Silicon with R 4.2") + cli::cli_bullets(c( + "Source: {.url https://mac.r-project.org/tools/}", + "Target installation: {.path {install_path}}" + )) + cli::cli_text("") # Add spacing + } + return(install_gfortran_12_arm(password = password, verbose = verbose)) + } + + if (is_r_version("4.1")) { + if (verbose) { + install_path <- install_location() + cli::cli_alert_info("{.pkg macrtools}: Installing gfortran 11 for Apple Silicon with R 4.1") + cli::cli_bullets(c( + "Source: {.url https://mac.r-project.org/libs-arm64/}", + "Target installation: {.path {install_path}}" + )) + cli::cli_text("") # Add spacing + } + return(install_gfortran_11_arm(password = password, verbose = verbose)) + } + + cli::cli_abort(c( + "{.pkg macrtools}: Unable to install gfortran for Apple Silicon (arm64/aarch64).", + "i" = "Official R support for Apple Silicon began in R 4.1.", + "i" = "Please upgrade R to version 4.1 or higher.", + "i" = "Visit https://cran.r-project.org/bin/macosx/ to download a compatible R version for Apple Silicon." + )) +} + #' Download and Install gfortran 8.2 for Intel Macs #' #' @noRd diff --git a/tests/testthat/test-gfortran.R b/tests/testthat/test-gfortran.R index 58680d8..c4195d1 100644 --- a/tests/testthat/test-gfortran.R +++ b/tests/testthat/test-gfortran.R @@ -96,7 +96,7 @@ test_that("gfortran_install uses the 14.2 universal installer for R 4.6", { expect_true(result) }) -test_that("gfortran_install handles Intel Mac with R 4.2", { +test_that("gfortran_install delegates to the legacy installer for R 4.2", { # Mock dependencies mockery::stub(gfortran_install, "assert_mac", function() NULL) mockery::stub(gfortran_install, "assert_macos_supported", function() NULL) @@ -104,15 +104,12 @@ test_that("gfortran_install handles Intel Mac with R 4.2", { mockery::stub(gfortran_install, "is_gfortran_installed", function() FALSE) mockery::stub(gfortran_install, "is_r_version_at_least", function(target, ...) utils::compareVersion("4.2", target) >= 0) - mockery::stub(gfortran_install, "is_r_version", function(v) v == "4.2") - mockery::stub(gfortran_install, "is_x86_64", function() TRUE) - mockery::stub(gfortran_install, "is_aarch64", function() FALSE) mockery::stub(gfortran_install, "gfortran_install_location", function() "/usr/local") mockery::stub(gfortran_install, "base::file.path", function(...) "/usr/local/gfortran/bin") mockery::stub(gfortran_install, "base::paste0", function(...) "$PATH:/usr/local/gfortran/bin") mockery::stub(gfortran_install, "force_password", function(pw) "mockpw") mockery::stub(gfortran_install, "create_install_location", function(...) TRUE) - mockery::stub(gfortran_install, "install_gfortran_82_mojave", function(...) TRUE) + mockery::stub(gfortran_install, "install_gfortran_legacy", function(...) TRUE) mockery::stub(gfortran_install, "renviron_gfortran_path", function(...) NULL) mockery::stub(gfortran_install, "cli::cli_alert_info", function(...) NULL) mockery::stub(gfortran_install, "cli::cli_bullets", function(...) NULL) @@ -123,6 +120,30 @@ test_that("gfortran_install handles Intel Mac with R 4.2", { expect_true(result) }) +test_that("install_gfortran_legacy dispatches by architecture and R version", { + # Intel -> gfortran 8.2 Mojave DMG installer + mockery::stub(install_gfortran_legacy, "is_x86_64", function() TRUE) + mockery::stub(install_gfortran_legacy, "install_gfortran_82_mojave", function(...) TRUE) + expect_true(install_gfortran_legacy("pw", verbose = FALSE)) + + # Apple Silicon + R 4.2 -> gfortran 12 arm tarball + mockery::stub(install_gfortran_legacy, "is_x86_64", function() FALSE) + mockery::stub(install_gfortran_legacy, "is_aarch64", function() TRUE) + mockery::stub(install_gfortran_legacy, "is_r_version", function(v) v == "4.2") + mockery::stub(install_gfortran_legacy, "install_gfortran_12_arm", function(...) TRUE) + expect_true(install_gfortran_legacy("pw", verbose = FALSE)) + + # Apple Silicon + R 4.0 -> abort (Apple Silicon began in R 4.1) + mockery::stub(install_gfortran_legacy, "is_r_version", function(v) FALSE) + expect_error(install_gfortran_legacy("pw", verbose = FALSE), "Apple Silicon") + + # Neither Intel nor Apple Silicon -> unsupported architecture abort + mockery::stub(install_gfortran_legacy, "is_x86_64", function() FALSE) + mockery::stub(install_gfortran_legacy, "is_aarch64", function() FALSE) + mockery::stub(install_gfortran_legacy, "system_arch", function() "ppc") + expect_error(install_gfortran_legacy("pw", verbose = FALSE), "Unsupported macOS architecture") +}) + test_that("gfortran_uninstall succeeds when not installed", { # Mock dependencies mockery::stub(gfortran_uninstall, "assert_mac", function() NULL) From ca9f62212483d50cbfd926c1f804687685c90fbe Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Tue, 23 Jun 2026 06:13:07 -0500 Subject: [PATCH 6/6] Regenerate documentation with roxygen2 8.0.0 Run devtools::document() to add man pages for the new internal version helpers and migrate RoxygenNote to Config/roxygen2/version (8.0.0). --- DESCRIPTION | 2 +- man/is_r_version_at_least.Rd | 20 ++++++++++++++++++++ man/is_r_version_supported.Rd | 19 +++++++++++++++++++ man/r_version_full.Rd | 15 +++++++++++++++ man/r_version_major_minor.Rd | 15 +++++++++++++++ man/recipes_binary_install.Rd | 1 + man/supported_r_version.Rd | 28 ++++++++++++++++++++++++++++ 7 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 man/is_r_version_at_least.Rd create mode 100644 man/is_r_version_supported.Rd create mode 100644 man/r_version_full.Rd create mode 100644 man/r_version_major_minor.Rd create mode 100644 man/supported_r_version.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 4ed9863..076cec5 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -18,7 +18,6 @@ Description: Sets up the macOS Rtools compiled code toolchain. License: AGPL (>= 3) Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.3 OS_type: unix SystemRequirements: macOS Imports: @@ -34,3 +33,4 @@ Suggests: mockery, testthat (>= 3.0.0) Config/testthat/edition: 3 +Config/roxygen2/version: 8.0.0 diff --git a/man/is_r_version_at_least.Rd b/man/is_r_version_at_least.Rd new file mode 100644 index 0000000..f0a5904 --- /dev/null +++ b/man/is_r_version_at_least.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/system.R +\name{is_r_version_at_least} +\alias{is_r_version_at_least} +\title{Check if the Running R Version is At Least a Target} +\usage{ +is_r_version_at_least(target, version = r_version_major_minor()) +} +\arguments{ +\item{target}{Target R version (e.g. \code{"4.3"}) to compare against.} + +\item{version}{The version to test, defaults to the running R version.} +} +\value{ +TRUE if \code{version} is greater than or equal to \code{target}, FALSE otherwise +} +\description{ +Check if the Running R Version is At Least a Target +} +\keyword{internal} diff --git a/man/is_r_version_supported.Rd b/man/is_r_version_supported.Rd new file mode 100644 index 0000000..359e69a --- /dev/null +++ b/man/is_r_version_supported.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/system.R +\name{is_r_version_supported} +\alias{is_r_version_supported} +\title{Check if the Running R Version is Supported} +\usage{ +is_r_version_supported(version = r_version_major_minor()) +} +\arguments{ +\item{version}{The version to test, defaults to the running R version.} +} +\value{ +TRUE if \code{version} falls within the supported window (inclusive), +FALSE otherwise +} +\description{ +Check if the Running R Version is Supported +} +\keyword{internal} diff --git a/man/r_version_full.Rd b/man/r_version_full.Rd new file mode 100644 index 0000000..b57d2a3 --- /dev/null +++ b/man/r_version_full.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/system.R +\name{r_version_full} +\alias{r_version_full} +\title{Running R Version as a Full major.minor.patch String} +\usage{ +r_version_full() +} +\value{ +The running R version as a \code{"major.minor.patch"} string, e.g. \code{"4.6.0"}. +} +\description{ +Running R Version as a Full major.minor.patch String +} +\keyword{internal} diff --git a/man/r_version_major_minor.Rd b/man/r_version_major_minor.Rd new file mode 100644 index 0000000..09797ea --- /dev/null +++ b/man/r_version_major_minor.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/system.R +\name{r_version_major_minor} +\alias{r_version_major_minor} +\title{Running R Version as a Comparable major.minor String} +\usage{ +r_version_major_minor() +} +\value{ +The running R version as a \code{"major.minor"} string, e.g. \code{"4.6"}. +} +\description{ +Running R Version as a Comparable major.minor String +} +\keyword{internal} diff --git a/man/recipes_binary_install.Rd b/man/recipes_binary_install.Rd index 10345f8..603c02d 100644 --- a/man/recipes_binary_install.Rd +++ b/man/recipes_binary_install.Rd @@ -60,6 +60,7 @@ repository and the install path are either:\tabular{lll}{ \href{https://mac.r-project.org/bin/darwin23/arm64}{darwin23/arm64} \tab /opt/R/arm64 \tab macOS 14 (Sonoma), Apple Silicon (arm64), used by R 4.6 \cr } + The correct repository is detected automatically: the highest \code{darwin} version less than or equal to your macOS is selected for your architecture. R 4.6 on Apple Silicon (macOS 14+) therefore resolves to \code{darwin23/arm64}, diff --git a/man/supported_r_version.Rd b/man/supported_r_version.Rd new file mode 100644 index 0000000..00c7225 --- /dev/null +++ b/man/supported_r_version.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/system.R +\name{supported_r_version} +\alias{supported_r_version} +\alias{minimum_supported_r_version} +\alias{maximum_supported_r_version} +\title{Supported R Version Window} +\usage{ +minimum_supported_r_version() + +maximum_supported_r_version() +} +\value{ +A \code{"major.minor"} version string. +} +\description{ +Single source of truth for the oldest and newest R minor versions whose +macOS toolchain \code{macrtools} supports. +} +\details{ +To validate support for a new R release, bump +\code{\link[=maximum_supported_r_version]{maximum_supported_r_version()}}. A new toolchain \emph{branch} (e.g. in +\code{\link[=gfortran_install]{gfortran_install()}} or the \code{installers.R} helpers) is only required when +the toolchain itself changes for that release; the range checks +(\code{\link[=is_r_version_at_least]{is_r_version_at_least()}}) otherwise carry the newest tier forward +automatically. +} +\keyword{internal}