Take cache files as they are where there is no time to recompute

A file that does not match is recomputed, which is the right default and
useless ten minutes before a talk: a collocation analysis runs for hours,
and the stale file is overwritten on the way. withCachedResults() takes
the files as they are while an expression is evaluated, and puts the mode
back afterwards, so that a document can be knitted from what is on disk
without a stray options() call outliving it. mode = "offline" goes
further and refuses to compute at all, which turns a missing file into an
error now rather than an unfinished talk later.

The same is available document-wide through options(rkorap.cacheAs=) or
KORAP_CACHE_AS, following the rkorap.verbose and KORAP_VERBOSE pair
already there. A file used although it does not match is still reported,
so that outdated numbers do not pass unremarked, and the warning about a
refused file now names the two ways out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: Ied5d88ea452f5cc39f8ceafbbb4035575641a682
diff --git a/NAMESPACE b/NAMESPACE
index 299f17c..8be9693 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -33,6 +33,7 @@
 export(summarise)
 export(synsemanticStopwords)
 export(tidy)
+export(withCachedResults)
 export(year)
 exportClasses(KorAPConnection)
 exportClasses(KorAPCorpusStats)
diff --git a/NEWS.md b/NEWS.md
index e8838ad..2d265f8 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -2,6 +2,8 @@
 
 - **`blessCacheAs()`** vouches for a cache file that an older version wrote, so that it is used again as it is. Files from the 1.3.0.9000 development cycle already hold correctly computed scores, the corrections having landed there, but say nothing about the version that wrote them and would otherwise be recomputed. What actually produced a file is left as it stands and the blessing recorded beside it, so `cacheAsInfo()` keeps saying where the numbers come from. A file recording no parameters has nothing left to compare once blessed, and is then reused for any call naming it
 
+- **`withCachedResults()`** takes cache files as they are while an expression is evaluated, `mode = "offline"` additionally refusing to compute anything that is not in a file already - for when a talk starts in ten minutes and a collocation analysis takes hours. The same is available document-wide through `options(rkorap.cacheAs=)` or the `KORAP_CACHE_AS` environment variable, with the values `"check"` (the default), `"reuse"` and `"offline"`. A file used despite not matching is still reported, so that outdated numbers do not pass unremarked
+
 - **`cacheAsInfo()`** reads back what a `cacheAs` file was produced by: the parameters of the call, the KorAP instance, the index revision its corpus had at the time, and the version of RKorAPClient that wrote it. For a result kept next to a document, that is what says which numbers it rests on
 
 - **`cacheAs` is now offered by `frequencyQuery()`, `corpusStats()`, `collocationScoreQuery()` and `textMetadata()`** as well, not only by `collocationAnalysis()`. It is a different thing from the connection's `cache`, which is a transparent speed-up: a `cacheAs` file belongs to the caller and is what keeps an analysis reproducible, since KorAP corpora grow and the same query returns different numbers next year. That is worth having for the quick functions too, where caching for speed would be pointless
diff --git a/R/cacheAs.R b/R/cacheAs.R
index c39b305..10726d0 100644
--- a/R/cacheAs.R
+++ b/R/cacheAs.R
@@ -33,6 +33,62 @@
 #' @noRd
 cacheAsScoreVersion <- "1.4.0"
 
+#' How cache files are to be treated on this run
+#'
+#' `"check"`, the default, uses a file only for the call that produced it.
+#' `"reuse"` takes what a file holds whatever it says, and `"offline"` does the
+#' same but refuses to compute anything that is not in a file already - which is
+#' what you want when the talk starts in ten minutes. Set through
+#' `options(rkorap.cacheAs=)` or the `KORAP_CACHE_AS` environment variable, the
+#' option winning where both are given.
+#' @noRd
+cacheAsMode <- function() {
+  mode <- getOption("rkorap.cacheAs", default = NULL)
+  if (is.null(mode)) {
+    mode <- Sys.getenv("KORAP_CACHE_AS", unset = "check")
+  }
+  mode <- tolower(trimws(as.character(mode)))
+  if (!mode %in% c("check", "reuse", "offline")) {
+    stop(
+      sprintf(
+        "Unknown cacheAs mode '%s' - expected \"check\", \"reuse\" or \"offline\".",
+        mode
+      ),
+      call. = FALSE
+    )
+  }
+  mode
+}
+
+#' Take cached results as they are for the duration of an expression
+#'
+#' Sets the cacheAs mode (see [cacheAs]) while `expr` is evaluated and puts it
+#' back afterwards, so that a whole document can be knitted from the files that
+#' are there, without a stray `options()` call outliving it.
+#'
+#' @param expr the code to evaluate
+#' @param mode `"reuse"` to take what the files hold, `"offline"` to refuse
+#'   computing anything that is not in one already
+#' @return the value of `expr`
+#'
+#' @examples
+#' \dontrun{
+#' withCachedResults({
+#'   ca <- KorAPConnection() |> collocationAnalysis("Klima", cacheAs = "klima.rds")
+#'   freq <- KorAPConnection() |> frequencyQuery("Klima", cacheAs = "klima-freq.rds")
+#' })
+#' }
+#'
+#' @family cacheAs
+#' @export
+withCachedResults <- function(expr, mode = c("reuse", "offline")) {
+  mode <- match.arg(mode)
+  previous <- getOption("rkorap.cacheAs")
+  on.exit(options(rkorap.cacheAs = previous), add = TRUE)
+  options(rkorap.cacheAs = mode)
+  expr
+}
+
 #' Append .rds to a cache file name that does not end in it
 #' @noRd
 cacheAsFileName <- function(cacheAs) {
@@ -223,7 +279,18 @@
 #' @return the cached result, or `NULL` if there is none to use
 #' @noRd
 readCacheAs <- function(cacheAs, kco, record, what) {
+  mode <- cacheAsMode()
+
   if (!file.exists(cacheAs)) {
+    if (mode == "offline") {
+      stop(
+        sprintf(
+          "Cache file '%s' does not exist, and the cacheAs mode is \"offline\".",
+          cacheAs
+        ),
+        call. = FALSE
+      )
+    }
     return(NULL)
   }
 
@@ -237,11 +304,21 @@
     return(cached)
   }
 
+  if (mode != "check") {
+    warning(
+      sprintf("Cache file '%s' %s, and is used as it is.", cacheAs, reason),
+      call. = FALSE
+    )
+    return(cached)
+  }
+
   warning(
     sprintf(
       paste0(
         "Cache file '%s' %s.\n",
-        "It is recomputed and overwritten; pass a different cacheAs file name to keep it."
+        "It is recomputed and overwritten. To keep it, pass a different cacheAs ",
+        "file name, vouch for it with blessCacheAs(), or take it as it is with ",
+        "withCachedResults()."
       ),
       cacheAs, reason
     ),
diff --git a/man/blessCacheAs.Rd b/man/blessCacheAs.Rd
index 3cecdde..3eec16b 100644
--- a/man/blessCacheAs.Rd
+++ b/man/blessCacheAs.Rd
@@ -39,6 +39,7 @@
 }
 \seealso{
 Other cacheAs:
-\code{\link[=cacheAsInfo]{cacheAsInfo()}}
+\code{\link[=cacheAsInfo]{cacheAsInfo()}},
+\code{\link[=withCachedResults]{withCachedResults()}}
 }
 \concept{cacheAs}
diff --git a/man/cacheAsInfo.Rd b/man/cacheAsInfo.Rd
index 73324b3..1ed3b0b 100644
--- a/man/cacheAsInfo.Rd
+++ b/man/cacheAsInfo.Rd
@@ -31,6 +31,7 @@
 }
 \seealso{
 Other cacheAs:
-\code{\link[=blessCacheAs]{blessCacheAs()}}
+\code{\link[=blessCacheAs]{blessCacheAs()}},
+\code{\link[=withCachedResults]{withCachedResults()}}
 }
 \concept{cacheAs}
diff --git a/man/withCachedResults.Rd b/man/withCachedResults.Rd
new file mode 100644
index 0000000..03e4223
--- /dev/null
+++ b/man/withCachedResults.Rd
@@ -0,0 +1,37 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/cacheAs.R
+\name{withCachedResults}
+\alias{withCachedResults}
+\title{Take cached results as they are for the duration of an expression}
+\usage{
+withCachedResults(expr, mode = c("reuse", "offline"))
+}
+\arguments{
+\item{expr}{the code to evaluate}
+
+\item{mode}{\code{"reuse"} to take what the files hold, \code{"offline"} to refuse
+computing anything that is not in one already}
+}
+\value{
+the value of \code{expr}
+}
+\description{
+Sets the cacheAs mode (see \link{cacheAs}) while \code{expr} is evaluated and puts it
+back afterwards, so that a whole document can be knitted from the files that
+are there, without a stray \code{options()} call outliving it.
+}
+\examples{
+\dontrun{
+withCachedResults({
+  ca <- KorAPConnection() |> collocationAnalysis("Klima", cacheAs = "klima.rds")
+  freq <- KorAPConnection() |> frequencyQuery("Klima", cacheAs = "klima-freq.rds")
+})
+}
+
+}
+\seealso{
+Other cacheAs:
+\code{\link[=blessCacheAs]{blessCacheAs()}},
+\code{\link[=cacheAsInfo]{cacheAsInfo()}}
+}
+\concept{cacheAs}
diff --git a/tests/testthat/test-cache-as.R b/tests/testthat/test-cache-as.R
index 7b4b034..91e62f2 100644
--- a/tests/testthat/test-cache-as.R
+++ b/tests/testthat/test-cache-as.R
@@ -49,6 +49,63 @@
   expect_error(blessCacheAs(file.path(tempdir(), "no-such-cache.rds")), "does not exist")
 })
 
+test_that("withCachedResults takes a file as it is, and says so", {
+  kco <- offlineKco()
+  file <- tempfile(fileext = ".rds")
+  on.exit(unlink(file), add = TRUE)
+  legacy <- tibble::tibble(node = "Test", collocate = "c", logDice = 7)
+  saveRDS(legacy, file)
+
+  testthat::local_mocked_bindings(
+    collocatesQuery = function(...) stop("server must not be contacted"),
+    .package = "RKorAPClient"
+  )
+
+  expect_warning(
+    result <- withCachedResults(collocationAnalysis(kco, "Test", cacheAs = file)),
+    "used as it is"
+  )
+  expect_equal(result, legacy)
+  # and the mode does not outlive the expression
+  expect_null(getOption("rkorap.cacheAs"))
+})
+
+test_that("the offline mode refuses to compute what is not in a file", {
+  kco <- offlineKco()
+  missing <- file.path(tempdir(), "not-written-yet.rds")
+
+  expect_error(
+    withCachedResults(
+      collocationAnalysis(kco, "Test", cacheAs = missing),
+      mode = "offline"
+    ),
+    "offline"
+  )
+})
+
+test_that("the cacheAs mode comes from the option, then the environment", {
+  mode <- RKorAPClient:::cacheAsMode
+
+  expect_equal(mode(), "check")
+
+  local({
+    old <- Sys.getenv("KORAP_CACHE_AS", unset = NA)
+    on.exit(if (is.na(old)) Sys.unsetenv("KORAP_CACHE_AS") else Sys.setenv(KORAP_CACHE_AS = old))
+    Sys.setenv(KORAP_CACHE_AS = "reuse")
+    expect_equal(mode(), "reuse")
+    # the option wins where both are given
+    previous <- options(rkorap.cacheAs = "offline")
+    on.exit(options(previous), add = TRUE)
+    expect_equal(mode(), "offline")
+  })
+
+  local({
+    old <- options(rkorap.cacheAs = "nonsense")
+    on.exit(options(old))
+    expect_error(mode(), "Unknown cacheAs mode")
+  })
+})
+
 test_that("cacheAsInfo says what produced a file", {
   skip_if_offline()
   kco <- KorAPConnection(accessToken = NULL, verbose = FALSE)