From 42e42f0481542cc5b3820d5781bfea489f3f4895 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Mon, 29 Jun 2026 08:53:51 -0400 Subject: [PATCH] Remove `oak_sources` crate --- Cargo.lock | 22 - Cargo.toml | 1 - crates/ark/Cargo.toml | 1 - crates/oak_db/src/file_imports.rs | 2 +- crates/oak_sources/Cargo.toml | 33 -- crates/oak_sources/scripts/srcrefs.R | 104 ---- crates/oak_sources/src/base.rs | 138 ----- crates/oak_sources/src/cache.rs | 538 -------------------- crates/oak_sources/src/cran.rs | 131 ----- crates/oak_sources/src/download.rs | 56 -- crates/oak_sources/src/fs.rs | 39 -- crates/oak_sources/src/hash.rs | 9 - crates/oak_sources/src/installed_package.rs | 105 ---- crates/oak_sources/src/lib.rs | 24 - crates/oak_sources/src/srcref.rs | 303 ----------- crates/oak_sources/src/test.rs | 48 -- 16 files changed, 1 insertion(+), 1553 deletions(-) delete mode 100644 crates/oak_sources/Cargo.toml delete mode 100644 crates/oak_sources/scripts/srcrefs.R delete mode 100644 crates/oak_sources/src/base.rs delete mode 100644 crates/oak_sources/src/cache.rs delete mode 100644 crates/oak_sources/src/cran.rs delete mode 100644 crates/oak_sources/src/download.rs delete mode 100644 crates/oak_sources/src/fs.rs delete mode 100644 crates/oak_sources/src/hash.rs delete mode 100644 crates/oak_sources/src/installed_package.rs delete mode 100644 crates/oak_sources/src/lib.rs delete mode 100644 crates/oak_sources/src/srcref.rs delete mode 100644 crates/oak_sources/src/test.rs diff --git a/Cargo.lock b/Cargo.lock index 1378a48b1c..28663a81e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,7 +441,6 @@ dependencies = [ "oak_scan", "oak_semantic", "oak_source", - "oak_sources", "oak_srcref", "once_cell", "regex", @@ -2743,27 +2742,6 @@ dependencies = [ "ureq", ] -[[package]] -name = "oak_sources" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "etcetera", - "flate2", - "hex", - "log", - "oak_fs", - "oak_package_metadata", - "oak_r_process", - "serde", - "serde_json", - "sha2 0.11.0", - "tar", - "tempfile", - "ureq", -] - [[package]] name = "oak_srcref" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 96b3cc8138..19233b2f16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,7 +86,6 @@ oak_r_process = { path = "crates/oak_r_process" } oak_scan = { path = "crates/oak_scan" } oak_semantic = { path = "crates/oak_semantic" } oak_source = { path = "crates/oak_source" } -oak_sources = { path = "crates/oak_sources" } oak_srcref = { path = "crates/oak_srcref" } once_cell = "1.21.4" parking_lot = "0.12.5" diff --git a/crates/ark/Cargo.toml b/crates/ark/Cargo.toml index 0b430744c6..355f9cb453 100644 --- a/crates/ark/Cargo.toml +++ b/crates/ark/Cargo.toml @@ -56,7 +56,6 @@ oak_package_metadata.workspace = true oak_scan.workspace = true oak_semantic.workspace = true oak_source.workspace = true -oak_sources.workspace = true oak_srcref.workspace = true once_cell.workspace = true regex.workspace = true diff --git a/crates/oak_db/src/file_imports.rs b/crates/oak_db/src/file_imports.rs index 962f729552..e25ebc0f19 100644 --- a/crates/oak_db/src/file_imports.rs +++ b/crates/oak_db/src/file_imports.rs @@ -36,7 +36,7 @@ pub enum ImportLayer { /// first, so `stats` is highest-priority and `base` is lowest). /// Materialised as `Package` layers; packages absent from the /// workspace and library roots drop out (the LSP fills them in later -/// via `oak_sources`). +/// via its source handler). const DEFAULT_SEARCH_PATH: [&str; 7] = [ "stats", "graphics", diff --git a/crates/oak_sources/Cargo.toml b/crates/oak_sources/Cargo.toml deleted file mode 100644 index 0630eb8928..0000000000 --- a/crates/oak_sources/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "oak_sources" -version = "0.1.0" -authors.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true - -[dependencies] -anyhow.workspace = true -chrono = { workspace = true, features = ["serde"] } -etcetera.workspace = true -flate2.workspace = true -hex.workspace = true -log.workspace = true -oak_fs.workspace = true -oak_package_metadata.workspace = true -oak_r_process.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2.workspace = true -tar.workspace = true -tempfile = { workspace = true, optional = true } -ureq.workspace = true - -[dev-dependencies] -tempfile.workspace = true - -[features] -testing = ["dep:tempfile"] - -[lints] -workspace = true diff --git a/crates/oak_sources/scripts/srcrefs.R b/crates/oak_sources/scripts/srcrefs.R deleted file mode 100644 index cf8c5193c4..0000000000 --- a/crates/oak_sources/scripts/srcrefs.R +++ /dev/null @@ -1,104 +0,0 @@ -# Extract source code from an installed R package using srcref metadata. -# -# See also `lobstr::src()`: -# https://github.com/r-lib/lobstr/blob/85dd18d43e4612f98b05bb93019a7b12fb0bbf6b/R/src.R#L205 -# -# Arguments (via commandArgs): -# 1. package - Package name -# 2. version - Expected package version -# -# Library paths have been set ahead of time via the `R_LIBS` environment -# variable. -# -# On success: prints the concatenated source lines (with `#line` directives) to -# stdout. -# -# On failure: exits with code 1 (package unexpectedly not installed, version -# mismatch) or 2 (no srcrefs). - -args <- commandArgs(trailingOnly = TRUE) - -if (length(args) != 2L) { - message("'package' and 'version' are required arguments.") - quit(status = 1L) -} - -package <- args[[1L]] -version <- args[[2L]] - -# Make sure package is installed -path <- find.package(package, quiet = TRUE) -if (length(path) == 0L) { - message(paste0("Package '", package, "' is not installed")) - quit(status = 1L) -} - -# Make sure installed version matches the requested version. -# Normalize both to `package_version`s to handle versions like sf's 1.1-1. -version <- as.character(package_version(version)) -installed_version <- as.character(packageVersion(package)) -if (installed_version != version) { - message(paste0( - "Version mismatch for '", - package, - "': ", - "installed ", - installed_version, - ", requested ", - version - )) - quit(status = 1L) -} - -# Get the package namespace and find a function object. -# Functions will contain the srcrefs if srcrefs have been kept, and are what we -# can back out the full file structure from. -ns <- asNamespace(package) -exports <- getNamespaceExports(ns) - -fn <- NULL -for (name in exports) { - candidate <- get0(name, envir = ns, mode = "function") - if (!is.null(candidate)) { - fn <- candidate - break - } -} - -if (is.null(fn)) { - message(paste0("No functions found for package '", package, "'")) - quit(status = 2L) -} - -extract_lines <- function(fn) { - srcref <- attr(fn, "srcref") - if (is.null(srcref)) { - return(NULL) - } - - srcfile <- attr(srcref, "srcfile") - if (is.null(srcfile)) { - return(NULL) - } - - original <- srcfile$original - if (is.null(original)) { - return(NULL) - } - - lines <- original$lines - if (is.null(lines)) { - return(NULL) - } - - lines -} - -lines <- extract_lines(fn) - -if (is.null(lines)) { - message(paste0("No srcrefs found for package '", package, "'")) - quit(status = 2L) -} - -cat(lines, sep = "\n") diff --git a/crates/oak_sources/src/base.rs b/crates/oak_sources/src/base.rs deleted file mode 100644 index 3ae3eb9588..0000000000 --- a/crates/oak_sources/src/base.rs +++ /dev/null @@ -1,138 +0,0 @@ -use std::io::Cursor; -use std::io::Read; - -use flate2::read::GzDecoder; -use oak_fs::file_lock::FileLock; - -use crate::download::Outcome; - -/// Names of the R base packages, i.e. everything that ships with R and carries -/// `Priority: base` in its DESCRIPTION. -pub(crate) const BASE_PACKAGES: &[&str] = &[ - "base", - "compiler", - "datasets", - "graphics", - "grDevices", - "grid", - "methods", - "parallel", - "splines", - "stats", - "stats4", - "tcltk", - "tools", - "utils", -]; - -/// Download the R source tarball for R {version} from CRAN's archive. -/// -/// Base R packages (e.g. `base`, `utils`, `stats`) are not distributed at the standard -/// `src/contrib/` location on CRAN. Instead, we must retrieve them from the base R -/// sources themselves, which lives at `src/base/R-{major}/R-{version}.tar.gz`. Each -/// package is located inside that tarball at `src/library/{package}/`. -/// -/// Returns `Ok(None)` if the tarball is not on CRAN (e.g. a development R version), which -/// we treat as "source unavailable" rather than an error. -pub(crate) fn download(version: &str) -> anyhow::Result>> { - let major = version - .split('.') - .next() - .ok_or_else(|| anyhow::anyhow!("Invalid R version for base source download: {version}"))?; - - let mirrors = ["https://cran.r-project.org", "https://cran.rstudio.com"]; - let suffix = format!("src/base/R-{major}/R-{version}.tar.gz"); - - match crate::download::download_with_mirrors(&suffix, &mirrors)? { - Outcome::Success(response) => { - let mut bytes = Vec::new(); - response.into_body().into_reader().read_to_end(&mut bytes)?; - Ok(Some(bytes)) - }, - Outcome::NotFound => Ok(None), - } -} - -/// Extract a single base package's R files from the R source tarball bytes. -/// -/// Writes `R-{version}/src/library/{package}/R/*.R` entries into an `R/` folder inside -/// the directory `destination_lock` lives in. Files are marked read only to match the -/// rest of the cache. -pub(crate) fn extract( - package: &str, - version: &str, - bytes: &[u8], - destination_lock: &FileLock, -) -> anyhow::Result<()> { - let destination = destination_lock.parent().join("R"); - std::fs::create_dir(&destination)?; - - let cursor = Cursor::new(bytes); - let gz = GzDecoder::new(cursor); - let mut archive = tar::Archive::new(gz); - - let prefix = format!("R-{version}/src/library/{package}/R/"); - - for entry in archive.entries()? { - let mut entry = entry?; - let path = entry.path()?; - - let Some(relative) = path.strip_prefix(&prefix).ok() else { - continue; - }; - - if relative - .extension() - .is_none_or(|ext| ext != "R" && ext != "r") - { - continue; - } - - let absolute = destination.join(relative); - - // Some base packages (e.g. `utils`) have platform-specific subdirs under `R/` - // like `R/windows/` and `R/unix/` (their `Makefile` handles them at install - // time). Create parents if one is required so `unpack()` can write nested files. - if let Some(parent) = relative.parent().filter(|p| !p.as_os_str().is_empty()) { - std::fs::create_dir_all(destination.join(parent))?; - } - - entry.unpack(&absolute)?; - crate::fs::set_readonly(&absolute)?; - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use oak_fs::file_lock::Filesystem; - use tempfile::TempDir; - - use crate::base::download; - use crate::base::extract; - - /// Requires internet access and downloads a large tarball of the R sources - #[ignore = "Downloads a 40mb tarball"] - #[test] - fn test_base_download_and_extract() { - let bytes = download("4.5.0").unwrap().expect("R 4.5.0 source to exist"); - - let destination_tempdir = TempDir::new().unwrap(); - let destination = Filesystem::new(destination_tempdir.path().to_path_buf()); - let destination_lock = destination.open_rw_exclusive_create(".lock").unwrap(); - - extract("utils", "4.5.0", &bytes, &destination_lock).unwrap(); - - // Spot check: `utils` has a well-known `help.R` file - let help = destination_lock.parent().join("R").join("help.R"); - assert!(help.exists()); - assert!(help.metadata().unwrap().permissions().readonly()); - } - - #[test] - fn test_base_download_unknown_version_returns_none() { - let bytes = download("0.0.0").unwrap(); - assert!(bytes.is_none()); - } -} diff --git a/crates/oak_sources/src/cache.rs b/crates/oak_sources/src/cache.rs deleted file mode 100644 index b7cc4dc218..0000000000 --- a/crates/oak_sources/src/cache.rs +++ /dev/null @@ -1,538 +0,0 @@ -use std::collections::HashSet; -use std::path::Path; -use std::path::PathBuf; -use std::sync::RwLock; - -use chrono::DateTime; -use chrono::TimeDelta; -use chrono::Utc; -use oak_fs::file_lock; -use oak_fs::file_lock::FileLock; -use oak_package_metadata::description::Priority; -use oak_package_metadata::description::Repository; -use serde::Deserialize; -use serde::Serialize; - -use crate::installed_package::InstalledPackage; - -/// Name of the root lock file and the per-key lock file. -const LOCK_FILENAME: &str = ".lock"; - -/// Name of the completion sentinel written last in each cache entry. -const METADATA_FILENAME: &str = ".metadata"; - -/// Minimum age before `clean()` will evict a stale entry. -/// -/// Gives users a window to revert CRAN <-> dev swaps (or similar) without losing the -/// cached CRAN build they had before the swap (dev builds will always be dropped due to -/// the DESCRIPTION `Build:` timestamp being different). -const ONE_WEEK: TimeDelta = TimeDelta::weeks(1); - -impl crate::PackageSources for PackageCache { - fn get(&self, package: &str) -> Option { - self.get(package) - } -} - -/// A cache of extracted R package sources -/// -/// # On disk layout -/// -/// The on disk cache at `/oak/sources/v1/` is the source of truth -/// across all sessions. Each entry is a flat folder: -/// -/// ```text -/// /oak/sources/v1/ -/// .lock -/// {package}_{version}_libpath-{hash}_description-{hash}/ -/// .lock -/// .metadata -/// DESCRIPTION -/// NAMESPACE -/// R/ -/// ``` -/// -/// Files other than `.lock` and `.metadata` are marked read only. `.metadata` -/// is written last and is the sole completion sentinel. An entry without it -/// is considered garbage and will be wiped by the next writer for that key. -/// -/// The combination of `libpath-{hash}` and `description-{hash}` are enough to make a -/// unique key. The same R package could be installed in multiple libraries (even for the -/// same R version), so libpath matters and is also recorded in `.metadata` as one of the -/// signals that allows us to clean up a stale source folder. And the DESCRIPTION hash is -/// unique due to the `Built` field, which includes a timestamp of when the package was -/// built (either by CRAN or the user). -/// -/// The `{package}` and `{version}` components of the key are not required to make it -/// unique, but make for a nicer reading experience when goto-definition opens one of -/// these files in your editor. -/// -/// # Locking -/// -/// The cache root `.lock` can be locked as shared or exclusive: -/// -/// - **Shared root lock** is held for the lifetime of this `PackageCache`. The invariant -/// is that if you hold a shared root lock, you can read from or append new entries to -/// the cache, but never delete from it. Multiple sessions can hold this simultaneously. -/// The main purpose is to prevent cleanup from running so that any PathBuf handed out -/// by [`get`] stays valid while this lock is held. -/// -/// - **Exclusive root lock** is only attempted to be taken at startup to run [`clean`]. -/// Skipped if any other session already holds a shared root lock, which is fine, we -/// will just attempt to clean next time. -/// -/// There are also per-key exclusive locks at -/// `{package}_{version}_libpath-{hash}_description-{hash}/.lock`. When appending a new -/// entry to the cache, a per-key exclusive lock must be taken first to ensure that two -/// sessions don't write to the same directory simultaneously. -/// -/// This setup allows multiple sessions (or even one session) to add to the cache in -/// parallel, while guaranteeing that they cannot interfere with each other or a cleanup -/// run. -/// -/// This cache design is somewhat similar to cargo's model, except cargo doesn't hold -/// long running shared locks since each cargo command is pretty short lived. -/// -/// # Cleanup -/// -/// Cache cleanup is attempted once per session but is only possible if there are no other -/// sessions active (i.e. we can take an exclusive lock on the cache root). If we can, -/// then we check the `.metadata` file and use various strategies to see if the cache -/// folder is stale, including: -/// -/// - Doing nothing if it is younger than 1 week old (to avoid churn when switching -/// between CRAN and dev versions repeatedly) -/// - Deleting if the libpath it originated from no longer exists -/// - Deleting if the package it originated from no longer exists -/// - Deleting if the DESCRIPTION it originated from has changed -/// -/// [`get`]: PackageCache::get -/// [`clean`]: PackageCache::clean -#[derive(Debug)] -pub struct PackageCache { - /// Path to an R executable - r: PathBuf, - - /// Library paths to consider - library_paths: Vec, - - /// On disk cache directory root - cache_root: file_lock::Filesystem, - - /// Shared lock on the root `.lock`, held for the life of this cache. - /// - /// Blocks any other process from acquiring the root exclusive lock (the only thing - /// that can delete entries). That way, any `PathBuf` we hand out remains valid for - /// the life of this cache (as long as `PackageCache` itself is not dropped!). - cache_root_lock: FileLock, - - /// Set of packages which are installed, but we failed to populate their sources (from - /// CRAN or srcrefs). If we request sources for one of these packages a second time, - /// we don't attempt expensive source generation again. - /// - /// Inside an [RwLock] so that [PackageCache::get()] avoids being `mut`, allowing a - /// caller to wrap a [PackageCache] in an [std::sync::Arc] and call - /// [PackageCache::get()] in the background on a thread, acting as a form of - /// "prefetching". - source_unavailable: RwLock>, -} - -/// Completion sentinel for a cache entry, written last. Also used to determine if we can -/// clean the cache folder out. -#[derive(Serialize, Deserialize)] -struct Metadata { - package: String, - libpath: PathBuf, - description_hash: String, - generated_at: DateTime, -} - -impl PackageCache { - pub fn new(r: PathBuf, library_paths: Vec) -> anyhow::Result { - let cache_root = file_lock::Filesystem::new(crate::fs::sources_dir()?); - cache_root.create_dir()?; - - // Try to clean the cache. Only works if no other processes hold a shared root lock. - if let Some(cache_root_lock) = cache_root.try_open_rw_exclusive_create(LOCK_FILENAME)? { - if let Err(err) = Self::clean(&cache_root_lock) { - log::warn!("Failed to clean sources cache: {err:?}"); - } - drop(cache_root_lock); - } - - // Take shared lock for the lifetime of the cache so any paths we hand out stay valid - let cache_root_lock = cache_root.open_ro_shared_create(LOCK_FILENAME)?; - - Ok(Self { - r, - library_paths, - cache_root, - cache_root_lock, - source_unavailable: RwLock::new(HashSet::new()), - }) - } - - /// Get a package's cached source folder - /// - /// May spawn an R subprocess or download from CRAN (in a blocking manner) to - /// generate the sources, so keep that in mind when calling this. - pub fn get(&self, package: &str) -> Option { - match self.get_result(package) { - Ok(Some(sources)) => Some(sources), - Ok(None) => None, - Err(err) => { - log::error!("Failed to get sources for {package}: {err:?}"); - None - }, - } - } - - fn get_result(&self, package: &str) -> anyhow::Result> { - let Some(package) = InstalledPackage::find(package, &self.library_paths)? else { - // Not even installed - return Ok(None); - }; - - // Read path: completion sentinel present, already exists on disk - let destination = self.cache_root_lock.parent().join(package.key()); - if destination.join(METADATA_FILENAME).exists() { - return Ok(Some(destination)); - } - - // Check if we've already tried to generate sources for this installed package but - // failed. If so, refuse to attempt expensive source generation again. - if self - .source_unavailable - .read() - .is_ok_and(|set| set.contains(package.key())) - { - return Ok(None); - } - - // Write path - let result = if matches!(package.description().priority, Some(Priority::Base)) { - // R version to download is the same as the base package version - self.try_populate_base(&package.description().version, &self.library_paths) - } else { - self.try_populate(&package, &self.r, &self.library_paths) - }; - - match result { - Ok(true) => Ok(Some(destination)), - Ok(false) => { - // Unavailable for some reason, maybe package isn't on CRAN. - // Never try and generate sources again this session. - self.source_unavailable - .write() - .ok() - .map(|mut set| set.insert(package.key().to_string())); - Ok(None) - }, - Err(err) => { - // Errored for some reason during source generation, maybe a download failed. - // Never try and generate sources again this session. - log::error!( - "Failed to cache {name} {version}: {err:?}", - name = package.name(), - version = package.version() - ); - self.source_unavailable - .write() - .ok() - .map(|mut set| set.insert(package.key().to_string())); - Ok(None) - }, - } - } - - fn try_populate_base + std::fmt::Debug>( - &self, - version: &str, - library_paths: &[P], - ) -> anyhow::Result { - // Download the R sources in their entirety - let Some(bytes) = crate::base::download(version)? else { - log::trace!("No R source tarball on CRAN for version {version}"); - return Ok(false); - }; - - // Populate all base packages from the download - for package in crate::base::BASE_PACKAGES { - let Some(package) = InstalledPackage::find(package, library_paths)? else { - // It would be very odd to not find a base package - log::warn!("Can't find '{package}' package from scanning {library_paths:?}"); - return Ok(false); - }; - self.try_populate_base_package(&package, version, &bytes)?; - } - - Ok(true) - } - - fn try_populate_base_package( - &self, - package: &InstalledPackage, - version: &str, - bytes: &[u8], - ) -> anyhow::Result<()> { - // Take per-key exclusive lock - let destination = self.cache_root.join(package.key()); - destination.create_dir()?; - let destination_lock = destination.open_rw_exclusive_create(LOCK_FILENAME)?; - - // Another writer may have populated the key while we waited for an exclusive lock - if destination_lock.parent().join(METADATA_FILENAME).exists() { - return Ok(()); - } - - // Wipe any partial content from a prior writer that may have crashed before - // writing `.metadata`. - destination_lock.remove_siblings()?; - - crate::base::extract(package.name(), version, bytes, &destination_lock)?; - - crate::fs::copy_as_readonly( - package.description_path(), - destination_lock.parent().join("DESCRIPTION"), - )?; - - // The `base` package itself has no NAMESPACE, for now we generate an empty - // NAMESPACE, but eventually we will want to fully populate it with a - // pseudo-NAMESPACE. - if package.name() == "base" { - std::fs::write(destination_lock.parent().join("NAMESPACE"), "")?; - crate::fs::set_readonly(destination_lock.parent().join("NAMESPACE"))?; - } else { - crate::fs::copy_as_readonly( - package.namespace_path(), - destination_lock.parent().join("NAMESPACE"), - )?; - } - - crate::fs::copy_as_readonly( - package.index_path(), - destination_lock.parent().join("INDEX"), - )?; - - // Last! `.metadata` is the completion sentinel. - self.write_metadata(package, &destination_lock)?; - - Ok(()) - } - - /// Writes `DESCRIPTION`, `NAMESPACE`, and `R/` to the cache entry, if possible - fn try_populate, Q: AsRef>( - &self, - package: &InstalledPackage, - r: P, - library_paths: &[Q], - ) -> anyhow::Result { - // Take per-key exclusive lock - let destination = self.cache_root.join(package.key()); - destination.create_dir()?; - let destination_lock = destination.open_rw_exclusive_create(LOCK_FILENAME)?; - - // Another writer may have populated the key while we waited for an exclusive lock - if destination_lock.parent().join(METADATA_FILENAME).exists() { - return Ok(true); - } - - // Wipe any partial content from a prior writer that may have crashed before - // writing `.metadata`. - destination_lock.remove_siblings()?; - - if !self.write_r_files(package, r, library_paths, &destination_lock)? { - return Ok(false); - } - - crate::fs::copy_as_readonly( - package.description_path(), - destination_lock.parent().join("DESCRIPTION"), - )?; - crate::fs::copy_as_readonly( - package.namespace_path(), - destination_lock.parent().join("NAMESPACE"), - )?; - crate::fs::copy_as_readonly( - package.index_path(), - destination_lock.parent().join("INDEX"), - )?; - - // Last! Only write `.metadata` if all other writes succeed. It is our completion sentinal. - self.write_metadata(package, &destination_lock)?; - - Ok(true) - } - - fn write_r_files, Q: AsRef>( - &self, - package: &InstalledPackage, - r: P, - library_paths: &[Q], - destination_lock: &FileLock, - ) -> anyhow::Result { - // Try caching from srcref - match crate::srcref::cache_srcref( - package.name(), - &package.description().version, - destination_lock, - r, - library_paths, - ) { - Ok(true) => { - log::trace!( - "Cached {name} {version} from srcrefs.", - name = package.name(), - version = package.version() - ); - return Ok(true); - }, - Ok(false) => { - // Fall through - }, - Err(err) => { - // Fall through with log - log::warn!( - "Failed to cache {name} {version} from srcrefs: {err:?}", - name = package.name(), - version = package.version() - ); - }, - } - - // Try caching from CRAN - if matches!(package.description().repository, Some(Repository::CRAN)) { - match crate::cran::cache_cran(package.name(), package.version(), destination_lock) { - Ok(true) => { - log::trace!( - "Cached {name} {version} from CRAN download.", - name = package.name(), - version = package.version() - ); - return Ok(true); - }, - Ok(false) => { - // Fall through - }, - Err(err) => { - // Fall through with log - log::warn!( - "Failed to cache {name} {version} from CRAN download: {err:?}", - name = package.name(), - version = package.version() - ); - }, - } - } - - // TODO: Also consider Bioconductor as a source, which seem to have a `biocViews` - // field in their DESCRIPTION according to some renv docs. We will also need a - // Bioconductor version to download for. pkgcache has some R code that helps - // with this kind of thing. - // https://github.com/rstudio/renv/blob/c689571a0a6ce83a2f82a93b396f3a1ba87b0282/vignettes/package-sources.Rmd#L34-L50 - // https://github.com/r-lib/pkgcache/blob/05d430e907064c5dea822ff82d4257f0b5668070/R/bioc.R - - Ok(false) - } - - /// Writes the `.metadata` completion sentinel last. - /// - /// A reader that sees `.metadata` can trust the rest of the entry is complete - /// and stable (Other files are read only. Only `clean()` could remove them, - /// and `clean()` needs the root exclusive lock which no one can take while we - /// hold a shared lock). - fn write_metadata( - &self, - package: &InstalledPackage, - destination_lock: &FileLock, - ) -> anyhow::Result<()> { - let metadata = Metadata { - package: package.name().to_string(), - libpath: package.library_path().to_path_buf(), - description_hash: package.description_hash().to_string(), - generated_at: Utc::now(), - }; - let contents = serde_json::to_vec_pretty(&metadata)?; - std::fs::write(destination_lock.parent().join(METADATA_FILENAME), contents)?; - Ok(()) - } - - /// Walks all cache entries and evicts ones that are provably stale. - /// - /// Caller must hold the root exclusive lock. - fn clean(cache_root_lock: &file_lock::FileLock) -> anyhow::Result<()> { - let cache_root = cache_root_lock.parent(); - let now = Utc::now(); - - for entry in std::fs::read_dir(cache_root)? { - let entry = entry?; - let path = entry.path(); - - if !entry.file_type()?.is_dir() { - // i.e. `.lock` itself - continue; - } - - let metadata_path = path.join(METADATA_FILENAME); - - let Ok(metadata_contents) = std::fs::read_to_string(&metadata_path) else { - log::warn!( - "Cleaning {} due to missing or unreadable metadata", - path.display() - ); - crate::fs::remove_dir_all_or_warn(&path); - continue; - }; - - let metadata: Metadata = match serde_json::from_str(&metadata_contents) { - Ok(m) => m, - Err(err) => { - log::warn!( - "Cleaning {} due to unreadable metadata: {err:?}", - path.display() - ); - crate::fs::remove_dir_all_or_warn(&path); - continue; - }, - }; - - // Refuse to do anything if younger than 1 week. The user may be switching - // between CRAN and dev, and we want to keep the cache for the CRAN version - // around. - let age = now.signed_duration_since(metadata.generated_at); - if age < ONE_WEEK { - continue; - } - - if !metadata.libpath.exists() { - log::trace!("Cleaning {} due to nonexistent libpath", path.display()); - crate::fs::remove_dir_all_or_warn(&path); - continue; - } - - let package_path = metadata.libpath.join(&metadata.package); - - if !package_path.exists() { - log::trace!("Cleaning {} due to nonexistent package", path.display()); - crate::fs::remove_dir_all_or_warn(&path); - continue; - } - - let Ok(description_contents) = - std::fs::read_to_string(package_path.join("DESCRIPTION")) - else { - log::trace!("Cleaning {} due to missing DESCRIPTION", path.display()); - crate::fs::remove_dir_all_or_warn(&path); - continue; - }; - - if crate::hash::hash(&description_contents) != metadata.description_hash { - log::trace!("Cleaning {} due to changed DESCRIPTION", path.display()); - crate::fs::remove_dir_all_or_warn(&path); - continue; - } - } - - Ok(()) - } -} diff --git a/crates/oak_sources/src/cran.rs b/crates/oak_sources/src/cran.rs deleted file mode 100644 index 1f78459825..0000000000 --- a/crates/oak_sources/src/cran.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::path::Path; - -use flate2::read::GzDecoder; -use oak_fs::file_lock::FileLock; - -use crate::download::download_with_mirrors; -use crate::download::Outcome; - -/// Downloads an R package's source files from CRAN if possible and adds them to the cache -/// at the parent folder containing `destination_lock` -pub(crate) fn cache_cran( - package: &str, - version: &str, - destination_lock: &FileLock, -) -> anyhow::Result { - match download(package, version) { - Ok(Outcome::Success(response)) => { - let destination = destination_lock.parent().join("R"); - std::fs::create_dir(&destination)?; - extract(package, response, destination.as_path())?; - Ok(true) - }, - - Ok(Outcome::NotFound) => { - // "Not on CRAN" isn't an error - Ok(false) - }, - - Err(err) => Err(anyhow::anyhow!( - "Failed to download {package} {version}: {err:?}" - )), - } -} - -fn download(package: &str, version: &str) -> anyhow::Result { - let mirrors = ["https://cran.r-project.org", "https://cran.rstudio.com"]; - - // Try released version - let outcome = - download_with_mirrors(&format!("src/contrib/{package}_{version}.tar.gz"), &mirrors)?; - - if matches!(outcome, Outcome::Success(_)) { - return Ok(outcome); - } - - // Try archive - download_with_mirrors( - &format!("src/contrib/Archive/{package}/{package}_{version}.tar.gz"), - &mirrors, - ) -} - -fn extract( - package: &str, - response: ureq::http::Response, - destination: &Path, -) -> anyhow::Result<()> { - // Stream the response body through a gzip decoder wrapped in a tar archive reader - // so we can just iterate over entries. `into_reader()` is unlimited by default. - let reader = response.into_body().into_reader(); - let gz = GzDecoder::new(reader); - let mut archive = tar::Archive::new(gz); - - // Looking for files under `R/` - let prefix = format!("{package}/R/"); - - for entry in archive.entries()? { - let mut entry = entry?; - - let path = entry.path()?; - let path = path.to_string_lossy(); - - if !path.starts_with(&prefix) { - continue; - } - - let Some(relative) = path.strip_prefix(&prefix) else { - continue; - }; - - if !relative.ends_with(".R") && !relative.ends_with(".r") { - continue; - } - - let absolute = destination.join(relative); - - // Write to disk - entry.unpack(&absolute)?; - crate::fs::set_readonly(&absolute)?; - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use oak_fs::file_lock::Filesystem; - use tempfile::TempDir; - - use crate::cran::cache_cran; - - /// Requires internet access - #[test] - fn test_cran_r_files_exist_and_are_readonly() { - let destination_tempdir = TempDir::new().unwrap(); - let destination = Filesystem::new(destination_tempdir.path().to_path_buf()); - let destination_lock = destination.open_rw_exclusive_create(".lock").unwrap(); - - let ok = cache_cran("vctrs", "0.7.2", &destination_lock).unwrap(); - assert!(ok); - - let r_dir = destination_lock.parent().join("R"); - assert!(r_dir.exists()); - - for entry in std::fs::read_dir(&r_dir).unwrap() { - let entry = entry.unwrap(); - let metadata = entry.metadata().unwrap(); - assert!(metadata.permissions().readonly()); - } - } - - #[test] - fn test_cache_cran_not_found() { - let destination_tempdir = TempDir::new().unwrap(); - let destination = Filesystem::new(destination_tempdir.path().to_path_buf()); - let destination_lock = destination.open_rw_exclusive_create(".lock").unwrap(); - - let ok = cache_cran("definitely_not_a_package", "0.0.0", &destination_lock).unwrap(); - assert!(!ok); - } -} diff --git a/crates/oak_sources/src/download.rs b/crates/oak_sources/src/download.rs deleted file mode 100644 index 976d507618..0000000000 --- a/crates/oak_sources/src/download.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::time::Duration; - -const HTTP_NOT_FOUND: u16 = 404; -const HTTP_SERVICE_UNAVAILABLE: u16 = 503; - -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const GLOBAL_TIMEOUT: Duration = Duration::from_secs(40); - -/// Outcome of a CRAN mirror HTTP request -pub(crate) enum Outcome { - Success(ureq::http::Response), - NotFound, -} - -pub(crate) fn download_with_mirrors(suffix: &str, mirrors: &[&str]) -> anyhow::Result { - if mirrors.is_empty() { - panic!("`mirrors` can't be empty."); - } - - let mut last_error = None; - - for mirror in mirrors { - let url = format!("{mirror}/{suffix}"); - - let request = ureq::get(&url) - .config() - .timeout_connect(Some(CONNECT_TIMEOUT)) - .timeout_global(Some(GLOBAL_TIMEOUT)) - .build(); - - match request.call() { - Ok(response) => return Ok(Outcome::Success(response)), - - // Known to be not there, don't try any other mirrors - Err(ureq::Error::StatusCode(HTTP_NOT_FOUND)) => return Ok(Outcome::NotFound), - - // Try next mirror, this one is temporarily unavailable - Err(ureq::Error::StatusCode(HTTP_SERVICE_UNAVAILABLE)) => { - last_error = Some(Err(ureq::Error::StatusCode(HTTP_SERVICE_UNAVAILABLE).into())); - continue; - }, - - // Try next mirror, this one timed out - Err(ureq::Error::Timeout(timeout)) => { - last_error = Some(Err(ureq::Error::Timeout(timeout).into())); - continue; - }, - - // Some unhandled error occurred, bail - Err(err) => return Err(err.into()), - }; - } - - // Every mirror returned `HTTP_SERVICE_UNAVAILABLE` or timed out - last_error.expect("`mirrors` was non-empty and we always set `last_error`") -} diff --git a/crates/oak_sources/src/fs.rs b/crates/oak_sources/src/fs.rs deleted file mode 100644 index 1573b2f60c..0000000000 --- a/crates/oak_sources/src/fs.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::io; -use std::path::Path; -use std::path::PathBuf; - -use etcetera::BaseStrategy; - -fn cache_dir() -> anyhow::Result { - // Can fail if the home directory can't be found - Ok(etcetera::choose_base_strategy()?.cache_dir().join("oak")) -} - -pub(crate) fn sources_dir() -> anyhow::Result { - Ok(cache_dir()?.join("sources").join("v1")) -} - -/// Set a file's on disk permissions to read only -pub(crate) fn set_readonly>(path: P) -> io::Result<()> { - let mut permissions = std::fs::metadata(&path)?.permissions(); - permissions.set_readonly(true); - std::fs::set_permissions(path, permissions) -} - -pub(crate) fn copy_as_readonly, Q: AsRef>( - from: P, - to: Q, -) -> anyhow::Result<()> { - std::fs::copy(from.as_ref(), to.as_ref())?; - crate::fs::set_readonly(to.as_ref())?; - Ok(()) -} - -pub(crate) fn remove_dir_all_or_warn(path: &Path) { - if let Err(err) = std::fs::remove_dir_all(path) { - log::warn!( - "Failed to remove directory {path}: {err:?}", - path = path.display() - ); - } -} diff --git a/crates/oak_sources/src/hash.rs b/crates/oak_sources/src/hash.rs deleted file mode 100644 index 308805479c..0000000000 --- a/crates/oak_sources/src/hash.rs +++ /dev/null @@ -1,9 +0,0 @@ -use sha2::Digest; -use sha2::Sha256; - -/// Retain 8 ASCII characters for each hash fragment -pub(crate) fn hash(contents: &str) -> String { - let mut hash = hex::encode(Sha256::digest(contents)); - hash.truncate(8); - hash -} diff --git a/crates/oak_sources/src/installed_package.rs b/crates/oak_sources/src/installed_package.rs deleted file mode 100644 index aef8856b70..0000000000 --- a/crates/oak_sources/src/installed_package.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::fs::read_to_string; -use std::path::Path; -use std::path::PathBuf; - -use oak_package_metadata::description::Description; - -pub(crate) struct InstalledPackage { - key: String, - name: String, - library_path: PathBuf, - description: Description, - description_hash: String, -} - -impl InstalledPackage { - pub(crate) fn find>( - package: &str, - library_paths: &[P], - ) -> anyhow::Result> { - let mut library_path = None; - - for library_path_candidate in library_paths { - if library_path_candidate.as_ref().join(package).exists() { - library_path = Some(library_path_candidate.as_ref()); - break; - } - } - - let Some(library_path) = library_path else { - // Not installed - return Ok(None); - }; - - let package_path = library_path.join(package); - - let description_path = package_path.join("DESCRIPTION"); - let description_contents = read_to_string(&description_path)?; - let description = Description::parse(&description_contents)?; - - let library_path_hash = crate::hash::hash(library_path.to_string_lossy().as_ref()); - let description_hash = crate::hash::hash(&description_contents); - - // Flat key unique enough to handle: - // - The same R package across multiple libpaths - // - Reinstalling a dev R package without changing the version (0.1.0.9000) - let key = format!( - "{name}_{version}_libpath-{library_path_hash}_description-{description_hash}", - name = package, - version = &description.version, - library_path_hash = &library_path_hash, - description_hash = &description_hash - ); - - Ok(Some(Self { - key, - name: package.to_string(), - library_path: library_path.to_path_buf(), - description, - description_hash, - })) - } - - pub(crate) fn name(&self) -> &str { - &self.name - } - - pub(crate) fn version(&self) -> &str { - &self.description().version - } - - pub(crate) fn description(&self) -> &Description { - &self.description - } - - // Flat key unique enough to handle: - // - The same R package across multiple libpaths - // - Reinstalling a dev R package without changing the version (0.1.0.9000) - pub(crate) fn key(&self) -> &str { - &self.key - } - - pub(crate) fn library_path(&self) -> &Path { - self.library_path.as_path() - } - - pub(crate) fn package_path(&self) -> PathBuf { - self.library_path.join(&self.name) - } - - pub(crate) fn description_path(&self) -> PathBuf { - self.package_path().join("DESCRIPTION") - } - - pub(crate) fn namespace_path(&self) -> PathBuf { - self.package_path().join("NAMESPACE") - } - - pub(crate) fn index_path(&self) -> PathBuf { - self.package_path().join("INDEX") - } - - pub(crate) fn description_hash(&self) -> &str { - &self.description_hash - } -} diff --git a/crates/oak_sources/src/lib.rs b/crates/oak_sources/src/lib.rs deleted file mode 100644 index 4d13b4be2a..0000000000 --- a/crates/oak_sources/src/lib.rs +++ /dev/null @@ -1,24 +0,0 @@ -mod base; -mod cache; -mod cran; -mod download; -mod fs; -mod hash; -mod installed_package; -mod srcref; -#[cfg(any(test, feature = "testing"))] -pub mod test; - -use std::path::PathBuf; - -pub use cache::PackageCache; - -/// Trait for an object that can retrieve package sources -/// -/// Implemented by the main [crate::cache::PackageCache] itself, but also by -/// [crate::test::TestPackageCache] so that you can generate a test cache that doesn't -/// need internet access or access to a live R session. -pub trait PackageSources: std::fmt::Debug + Sync + Send { - /// Returns a package to a directory with an `R/` subdirectory holding package sources - fn get(&self, package: &str) -> Option; -} diff --git a/crates/oak_sources/src/srcref.rs b/crates/oak_sources/src/srcref.rs deleted file mode 100644 index 2a47b0868f..0000000000 --- a/crates/oak_sources/src/srcref.rs +++ /dev/null @@ -1,303 +0,0 @@ -use std::path::Path; - -use oak_fs::file_lock::FileLock; - -const SCRIPT: &str = include_str!("../scripts/srcrefs.R"); - -/// Extracts an R package's source files from srcref metadata if possible and adds -/// them to the cache at the parent folder containing `destination_lock` -/// -/// Launches a sidecar R session to read the srcrefs from the installed package. -pub(crate) fn cache_srcref, Q: AsRef>( - package: &str, - version: &str, - destination_lock: &FileLock, - r: P, - library_paths: &[Q], -) -> anyhow::Result { - let args = &[package, version]; - - // Set `R_LIBS` to ensure the correct R package libraries are checked - // `:` on Unix, `;` on Windows, see `?R_LIBS` - let library_paths = library_paths - .iter() - .map(|library_path| library_path.as_ref().to_string_lossy()) - .collect::>() - .join(if cfg!(windows) { ";" } else { ":" }); - let env = &[("R_LIBS", library_paths.as_str())]; - - let output = oak_r_process::run_text(r.as_ref(), SCRIPT, args, env)?; - - let code = output.status.code().unwrap_or(1); - - // Exit code 2 means no srcrefs - if code == 2 { - let stderr = String::from_utf8_lossy(&output.stderr); - log::trace!("R script returned with exit code {code} for {package} {version}: {stderr}"); - return Ok(false); - } - - // Any other unexpected failure (unexpectedly not installed or wrong version) - if code != 0 { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!( - "R script failed with exit code {code} for {package} {version}: {stderr}" - )); - } - - let stdout = String::from_utf8(output.stdout).map_err(|err| { - anyhow::anyhow!("R script output was not valid UTF-8 for {package} {version}: {err}") - })?; - - let files = parse_output(&stdout); - - let destination = destination_lock.parent().join("R"); - std::fs::create_dir(&destination)?; - - for (name, contents) in files { - let path = destination.join(name); - std::fs::write(&path, contents)?; - crate::fs::set_readonly(&path)?; - } - - Ok(true) -} - -/// Parses the concatenated srcref output into individual files. -/// -/// The output format uses `#line 1 ""` directives to separate files: -/// -/// ```text -/// #line 1 "/pkg/R/aaa.R" -/// -/// #line 1 "/pkg/R/bbb.R" -/// -/// ``` -fn parse_output(output: &str) -> Vec<(String, String)> { - let mut files: Vec<(String, String)> = Vec::new(); - let mut current_name: Option = None; - let mut current_lines: Vec<&str> = Vec::new(); - - // This discards any lines before the first line directive, like this line, which - // isn't part of the package sources `.packageName <- "vctrs"` - for line in output.lines() { - if let Some(name) = parse_line_directive(line) { - // Flush the previous file - if let Some(current_name) = current_name.take() { - files.push((current_name, current_lines.join("\n"))); - current_lines.clear(); - } - current_name = Some(name); - } else { - if current_name.is_some() { - current_lines.push(line); - } - } - } - - // Flush the last file - if let Some(name) = current_name.take() { - files.push((name, current_lines.join("\n"))); - } - - files -} - -/// Extracts the filename from a line directive, keeping just the basename. -/// -/// For example, `line 1 "/pkg/R/aaa.R"` becomes `aaa.R`. -/// -/// Does not have a way to fail on malformed line directives. If for some reason one is -/// malformed and doesn't have our expected prefix/suffix, then we would end up treating -/// it like a comment within a file. -fn parse_line_directive(line: &str) -> Option { - let path = line.strip_prefix("#line 1 \"")?; - let path = path.strip_suffix('"')?; - let file_name = Path::new(path).file_name()?; - Some(file_name.to_string_lossy().into_owned()) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use oak_fs::file_lock::Filesystem; - - use super::*; - - #[test] - fn test_parse_output_single_file() { - let output = "\ -#line 1 \"/path/to/pkg/R/aaa.R\" -fn_a <- function() { - 1 + 1 -}"; - let files = parse_output(output); - assert_eq!(files.len(), 1); - assert_eq!(files[0].0, "aaa.R"); - assert_eq!(files[0].1, "fn_a <- function() {\n 1 + 1\n}"); - } - - #[test] - fn test_parse_output_multiple_files() { - let output = "\ -#line 1 \"/path/to/pkg/R/aaa.R\" -fn_a <- function() { } -#line 1 \"/path/to/pkg/R/bbb.R\" -fn_b <- function() { }"; - let files = parse_output(output); - assert_eq!(files.len(), 2); - assert_eq!(files[0].0, "aaa.R"); - assert_eq!(files[0].1, "fn_a <- function() { }"); - assert_eq!(files[1].0, "bbb.R"); - assert_eq!(files[1].1, "fn_b <- function() { }"); - } - - #[test] - fn test_parse_output_leading_text() { - let output = "\ -packageName <- \"vctrs\" -#line 1 \"/path/to/pkg/R/aaa.R\" -fn_a <- function() { - 1 + 1 -}"; - let files = parse_output(output); - assert_eq!(files.len(), 1); - assert_eq!(files[0].0, "aaa.R"); - assert_eq!(files[0].1, "fn_a <- function() {\n 1 + 1\n}"); - } - - #[test] - fn test_parse_output_empty() { - let files = parse_output(""); - assert!(files.is_empty()); - } - - #[test] - fn test_parse_output_no_directives() { - let files = parse_output("just some text\nwith no directives"); - assert!(files.is_empty()); - } - - #[test] - fn test_parse_line_directive() { - assert_eq!( - parse_line_directive("#line 1 \"/path/to/file.R\""), - Some(String::from("file.R")) - ); - assert_eq!(parse_line_directive("not a directive"), None); - assert_eq!(parse_line_directive("#line 2 \"/path/to/file.R\""), None); - assert_eq!(parse_line_directive("#line 1 missing-quotes"), None); - } - - /// Requires R on the PATH and internet access - /// - /// Installs source version of {generics} from CRAN into a temporary library, then - /// extracts the R source files via srcref metadata. We use {generics} because it - /// is very easy to install from source and lightweight. - #[test] - fn test_srcref_extraction() { - use std::process::Command; - - // Find R on PATH. - // On Windows, `which` (from Git) returns POSIX paths that `Command::new()` can't - // resolve. Use `where` which returns native paths. - let output = Command::new(if cfg!(windows) { "where" } else { "which" }) - .arg("R") - .output() - .unwrap_or_else(|err| panic!("Failed to find R: {err}")); - assert!(output.status.success()); - - // Parse (`where` on Windows can return multiple matches, take the first) - let r = PathBuf::from( - String::from_utf8(output.stdout) - .expect("Non-UTF8 R path") - .trim() - .lines() - .next() - .expect("R should exist"), - ); - - // Get base libpaths from R - let output = oak_r_process::run_text( - &r, - r#"cat(normalizePath(.libPaths()), sep = "\n")"#, - &[], - &[], - ) - .expect("Failed to get .libPaths()"); - assert!(output.status.success(), "Failed to query .libPaths()"); - - let r_libpaths_original: Vec = String::from_utf8(output.stdout) - .expect("Non-UTF8 libpaths") - .trim() - .lines() - .map(PathBuf::from) - .collect(); - - // Temporary library for installing generics - let r_libpaths = tempfile::TempDir::new().unwrap(); - - // Use forward slashes so the path is safe inside R string literals on Windows - // (backslashes would be interpreted as escape sequences). - let r_libpaths_for_interpolation = - r_libpaths.path().display().to_string().replace('\\', "/"); - - // Install generics from CRAN source with srcrefs preserved - let output = oak_r_process::run_text( - &r, - &format!( - r#"install.packages("generics", lib = "{r_libpaths_for_interpolation}", repos = "https://cran.r-project.org", type = "source", INSTALL_opts = "--with-keep.source")"#, - ), - &[], - &[], - ) - .expect("Failed to run install.packages()"); - assert!(output.status.success()); - - // Query the installed generics version - let output = oak_r_process::run_text( - &r, - &format!( - r#"cat(as.character(packageVersion("generics", lib.loc = "{r_libpaths_for_interpolation}")))"#, - ), - &[], - &[], - ) - .expect("Failed to get generics version"); - assert!(output.status.success()); - - let version = String::from_utf8(output.stdout) - .expect("Non-UTF8 version") - .trim() - .to_string(); - - // Prepend our temp library so generics is found there first - let mut all_libpaths = vec![r_libpaths.path().to_path_buf()]; - all_libpaths.extend(r_libpaths_original); - - // Cache destination - let destination_tempdir = tempfile::TempDir::new().unwrap(); - let destination = Filesystem::new(destination_tempdir.path().to_path_buf()); - let destination_lock = destination.open_rw_exclusive_create(".lock").unwrap(); - - let ok = cache_srcref("generics", &version, &destination_lock, &r, &all_libpaths).unwrap(); - assert!(ok); - - // Verify R source files were written - let r_dir = destination_lock.parent().join("R"); - assert!(r_dir.exists()); - - let entries: Vec<_> = std::fs::read_dir(&r_dir) - .unwrap() - .collect::, _>>() - .unwrap(); - assert!(!entries.is_empty()); - - // Verify files are readonly - for entry in &entries { - let metadata = entry.metadata().unwrap(); - assert!(metadata.permissions().readonly()); - } - } -} diff --git a/crates/oak_sources/src/test.rs b/crates/oak_sources/src/test.rs deleted file mode 100644 index 162ecd5024..0000000000 --- a/crates/oak_sources/src/test.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; - -use tempfile::TempDir; - -impl crate::PackageSources for TestPackageCache { - fn get(&self, package: &str) -> Option { - self.get(package) - } -} - -/// A fake package cache that can be used for testing -#[derive(Debug)] -pub struct TestPackageCache { - root: TempDir, -} - -impl TestPackageCache { - pub fn new() -> anyhow::Result { - let root = TempDir::new()?; - Ok(Self { root }) - } - - pub fn get(&self, package: &str) -> Option { - let package = self.root.path().join(package); - - if package.exists() { - Some(package) - } else { - None - } - } - - pub fn add(&self, package: &str, files: Vec<(&Path, &str)>) -> anyhow::Result<()> { - let package = self.root.path().join(package); - std::fs::create_dir(&package)?; - - let r = package.join("R"); - std::fs::create_dir(&r)?; - - for (name, content) in files { - let path = r.join(name); - std::fs::write(path, content)?; - } - - Ok(()) - } -}