use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct LibrarySearch {
dirs: Vec<PathBuf>,
}
impl LibrarySearch {
pub fn discover(project_root: Option<&Path>, configured: &[PathBuf]) -> Self {
let r_home = std::env::var("R_HOME")
.ok()
.filter(|s| !s.is_empty())
.or_else(probe_r_home);
let env = move |k: &str| match k {
"R_HOME" => r_home.clone(),
other => std::env::var(other).ok(),
};
Self::assemble(project_root, configured, &env, home_dir())
}
pub fn dirs(&self) -> &[PathBuf] {
&self.dirs
}
pub fn find_package(&self, package: &str) -> Option<PathBuf> {
self.dirs.iter().find_map(|dir| {
let candidate = dir.join(package);
candidate.join("DESCRIPTION").is_file().then_some(candidate)
})
}
fn assemble(
project_root: Option<&Path>,
configured: &[PathBuf],
env: &dyn Fn(&str) -> Option<String>,
home: Option<PathBuf>,
) -> Self {
let mut dirs: Vec<PathBuf> = Vec::new();
let push = |dirs: &mut Vec<PathBuf>, p: PathBuf| {
if !dirs.contains(&p) {
dirs.push(p);
}
};
for p in configured {
push(&mut dirs, p.clone());
}
if let Some(root) = project_root {
for p in project_overlay_dirs(root) {
push(&mut dirs, p);
}
}
for key in ["R_LIBS_USER", "R_LIBS_SITE", "R_LIBS"] {
if let Some(val) = env(key) {
for p in split_path_list(&val) {
push(&mut dirs, p);
}
}
}
if let Some(home) = home.as_ref() {
for p in default_user_libs(home) {
push(&mut dirs, p);
}
}
if let Some(r_home) = env("R_HOME")
&& !r_home.is_empty()
{
push(&mut dirs, PathBuf::from(r_home).join("library"));
}
LibrarySearch { dirs }
}
}
fn probe_r_home() -> Option<String> {
let output = std::process::Command::new("R").arg("RHOME").output().ok()?;
if !output.status.success() {
return None;
}
let home = String::from_utf8(output.stdout).ok()?.trim().to_string();
(!home.is_empty()).then_some(home)
}
fn split_path_list(value: &str) -> Vec<PathBuf> {
let sep = if cfg!(windows) { ';' } else { ':' };
value
.split(sep)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect()
}
fn project_overlay_dirs(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for container in [root.join("renv/library"), root.join("packrat/lib")] {
if container.is_dir() {
out.push(container.clone());
collect_shallow(&container, 2, &mut out);
}
}
out
}
fn collect_shallow(base: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(base) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.push(path.clone());
collect_shallow(&path, depth - 1, out);
}
}
}
fn default_user_libs(home: &Path) -> Vec<PathBuf> {
let mut out = vec![home.join(".R/library")];
let r_root = home.join("R");
if r_root.is_dir() {
collect_shallow(&r_root, 2, &mut out);
}
out
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env_from<'a>(map: &'a HashMap<&str, &str>) -> impl Fn(&str) -> Option<String> + 'a {
move |k| map.get(k).map(|s| s.to_string())
}
#[test]
fn splits_env_path_list_unix() {
if cfg!(windows) {
return;
}
let dirs = split_path_list("/a/lib:/b/lib::/c/lib");
assert_eq!(
dirs,
vec![
PathBuf::from("/a/lib"),
PathBuf::from("/b/lib"),
PathBuf::from("/c/lib"),
]
);
}
#[test]
fn configured_paths_come_first() {
let env = HashMap::new();
let search =
LibrarySearch::assemble(None, &[PathBuf::from("/custom/lib")], &env_from(&env), None);
assert_eq!(search.dirs().first(), Some(&PathBuf::from("/custom/lib")));
}
#[test]
fn reads_r_libs_env_in_priority_order() {
let site = if cfg!(windows) {
"/site/a;/site/b"
} else {
"/site/a:/site/b"
};
let mut env = HashMap::new();
env.insert("R_LIBS_USER", "/user/lib");
env.insert("R_LIBS_SITE", site);
let search = LibrarySearch::assemble(None, &[], &env_from(&env), None);
let dirs = search.dirs();
assert!(dirs.contains(&PathBuf::from("/user/lib")));
assert!(dirs.contains(&PathBuf::from("/site/a")));
assert!(dirs.contains(&PathBuf::from("/site/b")));
let ui = dirs
.iter()
.position(|d| d == Path::new("/user/lib"))
.unwrap();
let si = dirs.iter().position(|d| d == Path::new("/site/a")).unwrap();
assert!(ui < si);
}
#[test]
fn appends_r_home_system_library() {
let mut env = HashMap::new();
env.insert("R_HOME", "/opt/R");
let search = LibrarySearch::assemble(None, &[], &env_from(&env), None);
assert!(
search.dirs().contains(&PathBuf::from("/opt/R/library")),
"system library missing from {:?}",
search.dirs()
);
}
#[test]
fn finds_package_with_description() {
let tmp = tempfile::tempdir().unwrap();
let libdir = tmp.path().join("lib");
let pkgdir = libdir.join("magrittr");
std::fs::create_dir_all(&pkgdir).unwrap();
std::fs::write(pkgdir.join("DESCRIPTION"), "Package: magrittr\n").unwrap();
let mut env = HashMap::new();
let libdir_str = libdir.to_string_lossy().into_owned();
env.insert("R_LIBS", libdir_str.as_str());
let search = LibrarySearch::assemble(None, &[], &env_from(&env), None);
assert_eq!(search.find_package("magrittr"), Some(pkgdir));
assert_eq!(search.find_package("nonexistent"), None);
}
}