diff --git a/Cargo.lock b/Cargo.lock index 698d287e..1c2a614f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1814,6 +1814,7 @@ dependencies = [ "static_vcruntime", "sudo", "tabular", + "tempfile", "tokio", "version-ranges", "whoami", diff --git a/Cargo.toml b/Cargo.toml index 6a986e1e..09ac8aac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,4 +120,5 @@ whoami = "1.4.1" assert_cmd = "2.0.8" insta = "1.35" predicates = "2.1.5" +tempfile = "3" wiremock = "0.6" diff --git a/src/alias.rs b/src/alias.rs index 3acbdc51..99a6434c 100644 --- a/src/alias.rs +++ b/src/alias.rs @@ -24,6 +24,8 @@ use crate::linux::*; use crate::escalate::*; use crate::output::OUTPUT; +#[cfg(target_os = "macos")] +use crate::utils::{check_local_bin_path, get_binary_dir}; #[cfg(target_os = "macos")] pub fn get_alias(args: &ArgMatches) -> Option { @@ -65,15 +67,19 @@ pub fn get_alias(args: &ArgMatches) -> Option { #[cfg(target_os = "macos")] pub fn add_alias(ver: &str, alias: &str) -> Result<(), Box> { let msg = "Adding R-".to_string() + alias + " alias"; - escalate(&msg)?; + if crate::utils::get_mode()? == crate::utils::Mode::Admin { + escalate(&msg)?; + } + + check_local_bin_path()?; OUTPUT.status(&format!("Adding R-{} alias to R {}", alias, ver)); info!("Adding R-{} alias to R {}", alias, ver); - let rroot = get_r_root(); - let base = Path::new(&rroot); - let target = base.join(ver).join("Resources/bin/R"); - let linkfile = Path::new("/usr/local/bin/").join("R-".to_string() + alias); + let binding = get_r_binpath()?.replace("{}", ver); + let target = Path::new(&get_r_root()?).join(&binding); + let binary_dir = get_binary_dir()?; + let linkfile = Path::new(&binary_dir).join("R-".to_string() + alias); // If it exists then we check that it points to the right place // Cannot use .exists(), because it follows symlinks @@ -148,7 +154,7 @@ pub fn add_alias(ver: &str, alias: &str) -> Result<(), Box> { pub fn add_alias(ver: &str, alias: &str) -> Result<(), Box> { let msg = "Adding R-".to_string() + alias + " alias"; escalate(&msg)?; - let rroot = get_r_root_for(ver); + let rroot = get_r_root_for(ver)?; let base = version_dir_key(ver); let linkdir = Path::new(RIG_LINKS_DIR); @@ -185,7 +191,7 @@ pub fn add_alias(ver: &str, alias: &str) -> Result<(), Box> { OUTPUT.status(&format!("Adding R-{} alias to R {}", alias, ver)); info!("Adding R-{} alias to R {}", alias, ver); - let rroot = get_r_root(); + let rroot = get_r_root()?; let base = Path::new(&rroot); let target = base.join(ver).join("bin/R"); let linkfile = Path::new("/usr/local/bin/").join("R-".to_string() + alias); diff --git a/src/args.rs b/src/args.rs index 0000f56a..909f5d88 100644 --- a/src/args.rs +++ b/src/args.rs @@ -752,6 +752,55 @@ pub fn rig_app() -> Command { ), ); + #[cfg(target_os = "macos")] + { + let cmd_config = Command::new("config") + .about("Manage rig configuration") + .display_order(0) + .arg_required_else_help(true) + .subcommand( + Command::new("config-file-path") + .about("Print the path to the rig config file") + .display_order(0), + ) + .subcommand( + Command::new("list") + .about("List the names of all config entries") + .display_order(0) + .arg( + Arg::new("json") + .help("JSON output") + .long("json") + .num_args(0) + .required(false), + ), + ) + .subcommand( + Command::new("get") + .about("Get a config entry") + .display_order(0) + .arg(Arg::new("key").help("config key to get").required(true)) + .arg( + Arg::new("json") + .help("JSON output") + .long("json") + .num_args(0) + .required(false), + ), + ) + .subcommand( + Command::new("set") + .about("Set a config entry") + .display_order(0) + .arg( + Arg::new("keyvalue") + .help("key=value pair to set") + .required(true), + ), + ); + rig = rig.subcommand(cmd_config); + } + #[cfg(target_os = "macos")] { let cmd_sysreqs = Command::new("sysreqs") @@ -1288,7 +1337,29 @@ pub fn rig_app() -> Command { .long("json") .num_args(0) .required(false), - ) + ); + + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + rig = rig + .arg( + Arg::new("user") + .help("Run in user mode (overrides RIG_MODE and config)") + .long("user") + .global(true) + .action(clap::ArgAction::SetTrue) + .conflicts_with("admin"), + ) + .arg( + Arg::new("admin") + .help("Run in admin mode (overrides RIG_MODE and config)") + .long("admin") + .global(true) + .action(clap::ArgAction::SetTrue), + ); + } + + rig = rig .subcommand(cmd_default) .subcommand(cmd_list) .subcommand(cmd_add) diff --git a/src/common.rs b/src/common.rs index 1dce1353..bad473fa 100644 --- a/src/common.rs +++ b/src/common.rs @@ -48,10 +48,6 @@ pub fn check_installed(x: &String) -> Result> { bail!("R version {} is not installed", &x); } -pub fn get_r_base_profile(ver: &str) -> String { - R_BASE_PROFILE.replace("{}", &version_dir_key(ver)) -} - // -- rig default --------------------------------------------------------- // Fail if no default is set @@ -69,8 +65,10 @@ pub fn sc_get_default_or_fail() -> Result> { } pub fn set_default_if_none(ver: String) -> Result<(), Box> { + debug!("Checking if a default R version is set"); let cur = sc_get_default()?; if cur.is_none() { + debug!("No default R version is set, setting it to {}", ver); sc_set_default(&ver)?; } Ok(()) @@ -83,8 +81,8 @@ pub fn get_default_r_version() -> Result, Box> { None => Ok(None), Some(d) => { let name = check_installed(&d)?; - let desc = Path::new(&get_r_root_for(&name)) - .join(R_SYSLIBPATH.replace("{}", &version_dir_key(&name))) + let desc = Path::new(&get_r_root_for(&name)?) + .join(get_r_syslibpath()?.replace("{}", &version_dir_key(&name))) .join("base/DESCRIPTION"); let lines = match read_lines(&desc) { Ok(x) => x, @@ -106,8 +104,8 @@ pub fn get_default_r_version() -> Result, Box> { pub fn get_r_version_data_version(name: &str) -> Result> { let re = Regex::new("^Version:[ ]?").expect("Invalid regex pattern"); - let desc = Path::new(&get_r_root_for(name)) - .join(R_SYSLIBPATH.replace("{}", &version_dir_key(name))) + let desc = Path::new(&get_r_root_for(name)?) + .join(get_r_syslibpath()?.replace("{}", &version_dir_key(name))) .join("base/DESCRIPTION"); let lines = match read_lines(&desc) { Ok(x) => x, @@ -137,8 +135,8 @@ pub fn get_r_version_data( aliases: &[Alias], ) -> Result> { let version = Some(get_r_version_data_version(name)?); - let path = Path::new(&get_r_root_for(name)).join(R_VERSIONDIR.replace("{}", &version_dir_key(name))); - let binary = Path::new(&get_r_root_for(name)).join(R_BINPATH.replace("{}", &version_dir_key(name))); + let path = Path::new(&get_r_root_for(name)?).join(R_VERSIONDIR.replace("{}", &version_dir_key(name))); + let binary = Path::new(&get_r_root_for(name)?).join(get_r_binpath()?.replace("{}", &version_dir_key(name))); let mut myaliases: Vec = vec![]; for a in aliases { if a.version == name { @@ -159,7 +157,7 @@ pub fn sc_get_list_details() -> Result, Box> { let aliases = find_aliases()?; let mut res: Vec = vec![]; - for name in names { + for name in &names { res.push(get_r_version_data(&name, &aliases)?); } diff --git a/src/config.rs b/src/config.rs index 218f9194..9a682b78 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::error::Error; use std::path::PathBuf; +use clap::ArgMatches; + use simple_error::{bail, SimpleError}; use serde_derive::Deserialize; @@ -14,6 +16,8 @@ use crate::utils::*; struct Config { #[serde(default = "empty_stringmap")] userlibrary: HashMap, + #[serde(flatten)] + extra: HashMap, } fn empty_stringmap() -> HashMap { @@ -86,3 +90,137 @@ pub fn get_config(rver: &str, key: &str) -> Result, Box bail!("Unknown config key: {}, internal error", key), } } + +#[cfg(any(target_os = "windows", target_os = "linux"))] +pub fn sc_config(_args: &ArgMatches, _mainargs: &ArgMatches) -> Result<(), Box> { + // Cannot be called + Ok(()) +} + +#[cfg(target_os = "macos")] +pub fn sc_config(args: &ArgMatches, mainargs: &ArgMatches) -> Result<(), Box> { + match args.subcommand() { + Some(("config-file-path", _)) => sc_config_config_file_path(), + Some(("list", s)) => sc_config_list(s, mainargs), + Some(("get", s)) => sc_config_get(s, mainargs), + Some(("set", s)) => sc_config_set(s), + _ => Ok(()), + } +} + +#[cfg(target_os = "macos")] +fn sc_config_config_file_path() -> Result<(), Box> { + let path = rig_config_file()?; + println!("{}", path.display()); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn sc_config_get(args: &ArgMatches, mainargs: &ArgMatches) -> Result<(), Box> { + let key = args.get_one::("key").unwrap(); + let json = args.get_flag("json") || mainargs.get_flag("json"); + + let config_file = rig_config_file()?; + let root: serde_json::Value = if config_file.exists() { + let contents = read_file_string(&config_file)?; + serde_json::from_str(&contents)? + } else { + serde_json::Value::Object(serde_json::Map::new()) + }; + + let value = &root[key.as_str()]; + match value { + serde_json::Value::Null => { + if json { + println!("null"); + } + } + serde_json::Value::String(s) => { + if json { + println!("{}", serde_json::to_string(s)?); + } else { + println!("{}", s); + } + } + scalar @ (serde_json::Value::Bool(_) | serde_json::Value::Number(_)) => { + println!("{}", scalar); + } + complex => { + println!("{}", serde_json::to_string_pretty(complex)?); + } + } + Ok(()) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn load_raw_config() -> Result, Box> { + let config_file = rig_config_file()?; + if config_file.exists() { + let contents = read_file_string(&config_file)?; + let value: serde_json::Value = serde_json::from_str(&contents)?; + match value { + serde_json::Value::Object(map) => Ok(map), + _ => bail!("Config file is not a JSON object"), + } + } else { + Ok(serde_json::Map::new()) + } +} + +#[cfg(target_os = "macos")] +fn save_raw_config(map: &serde_json::Map) -> Result<(), Box> { + let config_file = rig_config_file()?; + let parent = config_file + .parent() + .ok_or(SimpleError::new("Invalid config file directory"))?; + std::fs::create_dir_all(parent)?; + std::fs::write(config_file, serde_json::to_string_pretty(map)?)?; + Ok(()) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub fn get_global_config_value(key: &str) -> Result, Box> { + let map = load_raw_config()?; + match map.get(key) { + Some(serde_json::Value::String(s)) => Ok(Some(s.clone())), + _ => Ok(None), + } +} + +#[cfg(target_os = "macos")] +fn sc_config_set(args: &ArgMatches) -> Result<(), Box> { + let keyvalue = args.get_one::("keyvalue").unwrap(); + let (key, value) = keyvalue + .split_once('=') + .ok_or_else(|| SimpleError::new(format!("Invalid key=value format: '{}'", keyvalue)))?; + let mut map = load_raw_config()?; + map.insert(key.to_string(), serde_json::Value::String(value.to_string())); + save_raw_config(&map) +} + +#[cfg(target_os = "macos")] +fn sc_config_list(args: &ArgMatches, mainargs: &ArgMatches) -> Result<(), Box> { + let config_file = rig_config_file()?; + let keys: Vec = if config_file.exists() { + let contents = read_file_string(&config_file)?; + let value: serde_json::Value = serde_json::from_str(&contents)?; + match value.as_object() { + Some(obj) => obj.keys().cloned().collect(), + None => vec![], + } + } else { + vec![] + }; + + if args.get_flag("json") || mainargs.get_flag("json") { + #[derive(serde::Serialize)] + struct Entry { key: String } + let entries: Vec = keys.into_iter().map(|k| Entry { key: k }).collect(); + println!("{}", serde_json::to_string_pretty(&entries)?); + } else { + for key in keys { + println!("{}", key); + } + } + Ok(()) +} diff --git a/src/escalate.rs b/src/escalate.rs index c1bf0bda..a072c644 100644 --- a/src/escalate.rs +++ b/src/escalate.rs @@ -36,6 +36,9 @@ pub fn escalate(task: &str) -> Result<(), Box> { ); with_env(&[ "RIG_HOME", + "RIG_BINARY_DIR", + "RIG_MODE", + "RIG_R_INSTALL_DIR", "RUST_BACKTRACE", "http_proxy", "https_proxy", diff --git a/src/help-macos.in b/src/help-macos.in index 65822bb3..cf768180 100644 --- a/src/help-macos.in +++ b/src/help-macos.in @@ -186,9 +186,9 @@ const HELP_SYSTEM_ORTHO: &str = " const HELP_SYSTEM_LINKS: &str = " \x1b[4m\x1b[1mDescription:\x1b[22m\x1b[24m - Create quick links in `/usr/local/bin` for the current R installations. - These let you directly run a specific R version. E.g. `R-4.1` will start - R 4.1.x. + Create quick links in the rig binary directory (`/usr/local/bin` by + default) for the current R installations. These let you directly run a + specific R version. E.g. `R-4.1` will start R 4.1.x. `rig add` runs `rig system make-links`, so if you only use rig to install R, then you do not need to run it manually. diff --git a/src/library.rs b/src/library.rs index ecac7953..bb7b6648 100644 --- a/src/library.rs +++ b/src/library.rs @@ -393,7 +393,16 @@ pub fn library_update_rprofile(rver: &str) -> Result<(), Box> { } if nmarkers == 0 { - escalate("updating user library configuration")?; + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + if get_mode()? == crate::utils::Mode::Admin { + escalate("updating user library configuration")?; + } + } + #[cfg(target_os = "windows")] + { + escalate("updating user library configuration")?; + } let newlines = r#" ## rig R_LIBS_USER start invisible(local({ diff --git a/src/linux.rs b/src/linux.rs index 713ae282..938923c1 100644 --- a/src/linux.rs +++ b/src/linux.rs @@ -28,11 +28,6 @@ use crate::utils::*; pub const R_ROOT_: &str = "/opt/R"; pub const R_VERSIONDIR: &str = "{}"; -pub const R_SYSLIBPATH: &str = "{}/lib/R/library"; -pub const R_BASE_PROFILE: &str = "{}/lib/R/library/base/R/Rprofile"; -pub const R_ETC_PATH: &str = "{}/lib/R/etc"; -pub const R_BINPATH: &str = "{}/bin/R"; -const R_CUR: &str = "/opt/R/current"; macro_rules! strvec { // match a list of expressions separated by comma: @@ -52,11 +47,14 @@ macro_rules! osvec { }); } -pub fn get_r_root() -> String { - R_ROOT_.to_string() +pub fn get_r_root() -> Result> { + if let Some(dir) = get_r_install_dir()? { + return Ok(dir); + } + Ok(R_ROOT_.to_string()) } -pub fn get_r_root_for(_name: &str) -> String { +pub fn get_r_root_for(_name: &str) -> Result> { get_r_root() } @@ -64,6 +62,41 @@ pub fn version_dir_key(name: &str) -> String { name.to_string() } +pub fn get_r_syslibpath() -> Result> { + if get_mode()? == Mode::User { + return Ok("{}/library".to_string()); + } + Ok("{}/lib/R/library".to_string()) +} + +pub fn get_r_binpath() -> Result> { + if get_mode()? == Mode::User { + return Ok("{}/R".to_string()); + } + Ok("{}/bin/R".to_string()) +} + +pub fn get_r_base_profile() -> Result> { + if get_mode()? == Mode::User { + return Ok("{}/library/base/R/Rprofile".to_string()); + } + Ok("{}/lib/R/library/base/R/Rprofile".to_string()) +} + +pub fn get_r_etc_path() -> Result> { + if get_mode()? == Mode::User { + return Ok("{}/etc".to_string()); + } + Ok("{}/lib/R/etc".to_string()) +} + +pub fn get_r_current() -> Result> { + if let Some(dir) = get_r_install_dir()? { + return Ok(format!("{}/current", dir)); + } + Ok("/opt/R/current".to_string()) +} + pub fn sc_add(args: &ArgMatches) -> Result<(), Box> { escalate("adding new R versions")?; @@ -367,7 +400,7 @@ pub fn sc_rm(args: &ArgMatches) -> Result<(), Box> { info!("{} package is not installed", pkgname); } - let rroot = get_r_root(); + let rroot = get_r_root()?; let dir = Path::new(&rroot); let dir = dir.join(&ver); if dir.exists() { @@ -385,7 +418,7 @@ pub fn sc_rm(args: &ArgMatches) -> Result<(), Box> { pub fn sc_system_make_links() -> Result<(), Box> { escalate("making R-* quick links")?; let vers = sc_get_list()?; - let rroot = get_r_root(); + let rroot = get_r_root()?; let base = Path::new(&rroot); // Create new links @@ -520,11 +553,11 @@ fn version_from_link(pb: PathBuf) -> Option { pub fn sc_get_list() -> Result, Box> { let mut vers = Vec::new(); - if !Path::new(&get_r_root()).exists() { + if !Path::new(&get_r_root()?).exists() { return Ok(vers); } - let paths = std::fs::read_dir(get_r_root())?; + let paths = std::fs::read_dir(get_r_root()?)?; for de in paths { let path = de?.path(); @@ -558,15 +591,16 @@ pub fn sc_set_default(ver: &str) -> Result<(), Box> { let ver = check_installed(&ver.to_string())?; trace!("Setting default version to {}", ver); + let cur = get_r_current()?; // Remove current link // We do not check if it exists, because that follows the symlink - trace!("Removing current at {}", R_CUR); - std::fs::remove_file(R_CUR).ok(); + trace!("Removing current at {}", cur); + std::fs::remove_file(&cur).ok(); // Add current link - let path = Path::new(&get_r_root()).join(ver); - trace!("Adding symlink at {}", R_CUR); - std::os::unix::fs::symlink(&path, R_CUR)?; + let path = Path::new(&get_r_root()?).join(ver); + trace!("Adding symlink at {}", cur); + std::os::unix::fs::symlink(&path, &cur)?; // Remove /usr/local/bin/R link let r = Path::new("/usr/local/bin/R"); @@ -592,7 +626,7 @@ pub fn sc_set_default(ver: &str) -> Result<(), Box> { } pub fn sc_get_default() -> Result, Box> { - read_version_link(R_CUR) + read_version_link(&get_r_current()?) } fn set_sysreqs_false(vers: Option>) -> Result<(), Box> { @@ -610,7 +644,7 @@ if (Sys.getenv("PKG_SYSREQS") == "") Sys.setenv(PKG_SYSREQS = "false") for ver in vers { let ver = check_installed(&ver)?; - let path = Path::new(&get_r_root()).join(ver.as_str()); + let path = Path::new(&get_r_root()?).join(ver.as_str()); let profile = path.join("lib/R/library/base/R/Rprofile".to_string()); if !profile.exists() { continue; @@ -688,7 +722,7 @@ pub fn sc_rstudio_( if let Some(ver) = version { let ver = check_installed(&ver.to_string())?; envname = "RSTUDIO_WHICH_R"; - path = get_r_root().to_string() + "/" + &ver + "/bin/R" + path = get_r_root()? + "/" + &ver + "/bin/R" }; if let Some(arg) = arg { @@ -713,21 +747,21 @@ pub fn sc_rstudio_( pub fn get_r_binary(rver: &str) -> Result> { debug!("Finding R binary for R {}", rver); - let bin = Path::new(&get_r_root()).join(rver).join("bin/R"); + let bin = Path::new(&get_r_root()?).join(rver).join("bin/R"); debug!("R {} binary is at {}", rver, bin.display()); Ok(bin) } #[allow(dead_code)] pub fn get_system_renviron(rver: &str) -> Result> { - let renviron = Path::new(&get_r_root()) + let renviron = Path::new(&get_r_root()?) .join(rver) .join("lib/R/etc/Renviron"); Ok(renviron) } pub fn get_system_profile(rver: &str) -> Result> { - let profile = Path::new(&get_r_root()) + let profile = Path::new(&get_r_root()?) .join(rver) .join("lib/R/library/base/R/Rprofile"); Ok(profile) @@ -751,7 +785,7 @@ fn check_usr_bin_sed(rver: &str) -> Result<(), Box> { return Ok(()); } - let makeconf = Path::new(&get_r_root()) + let makeconf = Path::new(&get_r_root()?) .join(rver) .join("lib/R/etc/Makeconf"); let lines: Vec = match read_lines(&makeconf) { diff --git a/src/macos.rs b/src/macos.rs index 464f04e4..2656836b 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -12,6 +12,7 @@ use clap::ArgMatches; use log::{debug, error, info, warn}; use nix::sys::stat::umask; use nix::sys::stat::Mode; +use nix::unistd::{access, AccessFlags}; use owo_colors::OwoColorize; use path_clean::PathClean; use regex::Regex; @@ -31,11 +32,6 @@ use crate::utils::*; pub const R_ROOT_: &str = "/Library/Frameworks/R.framework/Versions"; pub const R_VERSIONDIR: &str = "{}"; -pub const R_SYSLIBPATH: &str = "{}/Resources/library"; -pub const R_BINPATH: &str = "{}/Resources/R"; -pub const R_BASE_PROFILE: &str = "{}/Resources/library/base/R/Rprofile"; -pub const R_ETC_PATH: &str = "{}/Resources/etc"; -const R_CUR: &str = "/Library/Frameworks/R.framework/Versions/Current"; macro_rules! osvec { // match a list of expressions separated by comma: @@ -46,11 +42,16 @@ macro_rules! osvec { }); } -pub fn get_r_root() -> String { - R_ROOT_.to_string() +// /Library/Frameworks/R.framework/Versions +// ~/.local/share/rig/r +pub fn get_r_root() -> Result> { + if let Some(dir) = get_r_install_dir()? { + return Ok(dir); + } + Ok(R_ROOT_.to_string()) } -pub fn get_r_root_for(_name: &str) -> String { +pub fn get_r_root_for(_name: &str) -> Result> { get_r_root() } @@ -58,8 +59,59 @@ pub fn version_dir_key(name: &str) -> String { name.to_string() } +pub fn get_r_syslibpath() -> Result> { + if get_mode()? == crate::utils::Mode::User { + return Ok("{}/library".to_string()); + } + Ok("{}/Resources/library".to_string()) +} + +pub fn get_r_binpath() -> Result> { + if get_mode()? == crate::utils::Mode::User { + return Ok("{}/bin/R".to_string()); + } + Ok("{}/Resources/bin/R".to_string()) +} + +fn get_r_exec_binpath() -> Result> { + if get_mode()? == crate::utils::Mode::User { + return Ok("{}/bin/exec/R".to_string()); + } + Ok("{}/Resources/bin/exec/R".to_string()) +} + +pub fn get_r_default_bindir() -> Result> { + if get_mode()? == crate::utils::Mode::User { + return Ok(get_r_root()? + "/Current/bin"); + } + Ok(get_r_root()? + "/Current/Resources/bin") +} + +pub fn get_r_base_profile() -> Result> { + if get_mode()? == crate::utils::Mode::User { + return Ok("{}/library/base/R/Rprofile".to_string()); + } + Ok("{}/Resources/library/base/R/Rprofile".to_string()) +} + +pub fn get_r_etc_path() -> Result> { + if get_mode()? == crate::utils::Mode::User { + return Ok("{}/etc".to_string()); + } + Ok("{}/Resources/etc".to_string()) +} + +pub fn get_r_current() -> Result> { + if let Some(dir) = get_r_install_dir()? { + return Ok(format!("{}/Current", dir)); + } + Ok("/Library/Frameworks/R.framework/Versions/Current".to_string()) +} + pub fn sc_add(args: &ArgMatches) -> Result<(), Box> { - escalate("adding new R versions")?; + if get_mode()? == crate::utils::Mode::Admin { + escalate("adding new R versions")?; + } let mut version = get_resolve(args)?; let alias = get_alias(args); let ver = version.version.to_owned(); @@ -122,7 +174,16 @@ pub fn sc_add(args: &ArgMatches) -> Result<(), Box> { let dirname = fver.installdir; // Install without changing default - safe_install(target, &dirname, arch)?; + if get_mode()? == crate::utils::Mode::User { + let install_dir = std::path::PathBuf::from(get_r_root()?); + safe_user_install(target, &dirname, install_dir)?; + if let Err(e) = ensure_positron_custom_root_folders() { + OUTPUT.warn(&format!("Could not update Positron settings: {}", e)); + warn!("Could not update Positron settings: {}", e); + } + } else { + safe_install(target, &dirname, arch)?; + } // This should not happen currently on macOS, a .pkg installer // sets the default, but prepare for the future @@ -168,18 +229,15 @@ fn random_string() -> String { password } -fn safe_install( - target: std::path::PathBuf, - ver: &str, - arch: Option, -) -> Result<(), Box> { +fn unpack_and_patch( + target: &Path, +) -> Result<(PathBuf, PathBuf), Box> { let dir = target.parent().ok_or(SimpleError::new("Internal error"))?; - let tmpf = random_string(); - let tmp = dir.join(tmpf); + let tmp = dir.join(random_string()); let output = Command::new("pkgutil") .arg("--expand") - .arg(&target) + .arg(target) .arg(&tmp) .output()?; if !output.status.success() { @@ -205,6 +263,7 @@ fn safe_install( error!("Failed to patch installer, could not find framework"); bail!("Failed to patch installer, could not find framework"); }; + let output = Command::new("sh") .current_dir(&wd) .args(["-c", "gzip -dcf Payload | cpio -i"]) @@ -216,11 +275,48 @@ fn safe_install( bail!("Failed to extract installer: {}", err); } - let link = wd.join("R.framework").join("Versions").join("Current"); - make_orthogonal_(&link, ver)?; + Ok((tmp, wd)) +} + +fn run_fc_cache(fc_cache: &Path) { + if !fc_cache.exists() { + debug!("Skipping fc-cache; {} does not exist", fc_cache.display()); + return; + } + debug!("Running {}", fc_cache.display()); + match Command::new(fc_cache).output() { + Err(err) => { + OUTPUT.warn(&format!( + "Failed to run {}: {}", + fc_cache.display(), + err.to_string() + )); + warn!("Failed to run {}: {}", fc_cache.display(), err.to_string()); + } + Ok(output) if !output.status.success() => { + OUTPUT.warn(&format!( + "{} exited with {}", + fc_cache.display(), + output.status + )); + warn!("{} exited with {}", fc_cache.display(), output.status); + } + Ok(_) => {} + } +} +fn safe_install( + target: std::path::PathBuf, + ver: &str, + arch: Option, +) -> Result<(), Box> { + let (tmp, wd) = unpack_and_patch(&target)?; + let link: PathBuf = wd.join("R.framework").join("Versions").join("Current"); + make_orthogonal_(&link, ver)?; std::fs::remove_file(&link)?; + let dir = tmp.parent().ok_or(SimpleError::new("Internal error"))?; + let output = Command::new("sh") .current_dir(&wd) .args(["-c", "find R.framework | cpio -oz > Payload"]) @@ -274,6 +370,9 @@ fn safe_install( info!("Running installer"); run(cmd.into(), args, "installer")?; + let fc_cache = Path::new(R_ROOT_).join(ver).join("Resources").join("bin").join("fc-cache"); + run_fc_cache(&fc_cache); + if let Err(err) = std::fs::remove_file(&pkg) { OUTPUT.warn(&format!( "Failed to remove temporary installer {}: {}", @@ -302,8 +401,213 @@ fn safe_install( Ok(()) } +fn patch_user_r_script(source_dir: &Path, home_dir: &Path) -> Result<(), Box> { + let rfile = source_dir.join("bin").join("R"); + let re = Regex::new(r"(?m)^R_HOME_DIR=.*$")?; + // Escape `$` so it survives the regex-crate replacement expansion. + let home_escaped = home_dir.display().to_string().replace('$', "$$"); + let sub = format!( + "R_HOME_DIR={}\n\ + R_HOME_DIR=$$(cd \"$$(dirname \"$$(realpath \"$$0\")\")/..\" && pwd -P)\n\ + export DYLD_LIBRARY_PATH=\"$${{R_HOME_DIR}}/lib\"", + home_escaped + ); + debug!("Patching R_HOME_DIR in {}", rfile.display()); + replace_in_file(&rfile, &re, &sub)?; + + let re = Regex::new(r"/Library/Frameworks/R\.framework/Resources")?; + debug!("Patching framework references in {}", rfile.display()); + replace_in_file(&rfile, &re, "$${R_HOME}")?; + + Ok(()) +} + +fn patch_user_scripts(source_dir: &Path, home_dir: &Path) -> Result<(), Box> { + // Escape `$` so it survives the regex-crate replacement expansion. + let home_escaped = home_dir.display().to_string().replace('$', "$$"); + + let makeconf = source_dir.join("etc").join("Makeconf"); + let re = Regex::new(r"(?m)^LIBR\s*=.*$")?; + // `$$` so the regex replacement emits a literal `$` for `$(R_HOME)`. + let sub = "LIBR = -L\"$$(R_HOME)/lib\" -lR"; + debug!("Patching LIBR in {}", makeconf.display()); + replace_in_file(&makeconf, &re, sub)?; + + let renviron = source_dir.join("etc").join("Renviron"); + let re = Regex::new(r"(?m)^R_QPDF=.*$")?; + // R does not expand $R_HOME when reading Renviron, so bake home_dir in. + // `$$` for the `${R_QPDF-...}` shell-style fallback expansion. + let sub = format!("R_QPDF=$${{R_QPDF-{}/bin/qpdf}}", home_escaped); + debug!("Patching R_QPDF in {}", renviron.display()); + replace_in_file(&renviron, &re, &sub)?; + + let fonts = source_dir.join("fontconfig").join("fonts").join("fonts.conf"); + if fonts.exists() { + let re = Regex::new(r"/Library/Frameworks/R\.framework/Resources")?; + debug!("Patching fontconfig in {}", fonts.display()); + replace_in_file(&fonts, &re, &home_escaped)?; + } else { + debug!("Skipping fonts.conf patch; {} does not exist", fonts.display()); + } + + let libpc = source_dir.join("lib").join("pkgconfig").join("libR.pc"); + if libpc.exists() { + // Do `rincludedir` first so the next pass doesn't rewrite the framework + // path inside it before we replace the whole line. + let re = Regex::new(r"(?m)^rincludedir=.*$")?; + let sub = "rincludedir=$${rhome}/include"; + debug!("Patching rincludedir in {}", libpc.display()); + replace_in_file(&libpc, &re, sub)?; + let re = Regex::new(r"/Library/Frameworks/R\.framework/Versions/[^/]+/Resources")?; + debug!("Patching framework path in {}", libpc.display()); + replace_in_file(&libpc, &re, &home_escaped)?; + } else { + debug!("Skipping libR.pc patch; {} does not exist", libpc.display()); + } + + Ok(()) +} + +fn replace_user_rscript(source_dir: &Path) -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + // (path-to-Rscript, shell expression for RHOME relative to "$0") + let scripts: [(PathBuf, &str); 2] = [ + ( + source_dir.join("bin").join("Rscript"), + "$(cd \"$(dirname \"$(realpath \"$0\")\")/..\" && pwd -P)", + ), + ( + source_dir.join("Rscript"), + "$(cd \"$(dirname \"$(realpath \"$0\")\")\" && pwd -P)", + ), + ]; + + for (rscript, rhome_expr) in &scripts { + let rscript_orig = rscript.with_file_name("Rscript.orig"); + debug!("Renaming {} to {}", rscript.display(), rscript_orig.display()); + std::fs::rename(rscript, &rscript_orig)?; + + let content = format!( + "#!/bin/sh\n\ + RHOME={}\n\ + export RHOME\n\ + exec \"$(dirname \"$(realpath \"$0\")\")/Rscript.orig\" \"$@\"\n", + rhome_expr + ); + debug!("Writing wrapper Rscript to {}", rscript.display()); + std::fs::write(rscript, content)?; + let mut perms = std::fs::metadata(rscript)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(rscript, perms)?; + } + + Ok(()) +} + +fn replace_user_fontconfig(source_dir: &Path) -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let fc_cache = source_dir.join("bin").join("fc-cache"); + if !fc_cache.exists() { + debug!("Skipping fc-cache wrapper; {} does not exist", fc_cache.display()); + return Ok(()); + } + let fc_cache_orig = fc_cache.with_file_name("fc-cache.orig"); + debug!("Copying {} to {}", fc_cache.display(), fc_cache_orig.display()); + std::fs::copy(&fc_cache, &fc_cache_orig)?; + + let content = "#!/bin/sh\n\ + RHOME=$(cd \"$(dirname \"$(realpath \"$0\")\")/..\" && pwd -P)\n\ + FONTCONFIG_FILE=\"$RHOME/fontconfig/fonts/fonts.conf\"\n\ + export FONTCONFIG_FILE\n\ + exec \"$(dirname \"$(realpath \"$0\")\")/fc-cache.orig\" \"$@\"\n"; + debug!("Writing wrapper fc-cache to {}", fc_cache.display()); + std::fs::write(&fc_cache, content)?; + let mut perms = std::fs::metadata(&fc_cache)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&fc_cache, perms)?; + + Ok(()) +} + +// These are included on older R and shadow system libraries and cause crashes +fn remove_user_dyld_shadows(source_dir: &Path) -> Result<(), Box> { + let lib = source_dir.join("lib"); + let shadows = ["libc++.1.dylib", "libc++abi.1.dylib", "libunwind.1.dylib"]; + for name in shadows { + let path = lib.join(name); + match std::fs::remove_file(&path) { + Ok(()) => debug!("Removed system-shadowing dylib {}", path.display()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + debug!("No system-shadowing dylib at {}", path.display()); + } + Err(err) => return Err(err.into()), + } + } + Ok(()) +} + +fn safe_user_install( + target: std::path::PathBuf, + ver: &str, + install_dir: std::path::PathBuf, +) -> Result<(), Box> { + let (tmp, wd) = unpack_and_patch(&target)?; + let source_dir = wd.join("R.framework") + .join("Versions") + .join("Current") + .join("Resources"); + + let target_dir = install_dir.join(ver); + patch_user_r_script(&source_dir, &target_dir)?; + patch_user_scripts(&source_dir, &target_dir)?; + replace_user_rscript(&source_dir)?; + replace_user_fontconfig(&source_dir)?; + remove_user_dyld_shadows(&source_dir)?; + + debug!("Copying {} to {}", source_dir.display(), target_dir.display()); + let output = Command::new("ditto") + .arg(&source_dir) + .arg(&target_dir) + .output()?; + if !output.status.success() { + let err = output.status.to_string(); + OUTPUT.error(&format!("Failed to copy R framework: {}", err)); + error!("Failed to copy R framework: {}", err); + bail!("Failed to copy R framework: {}", err); + } + + // Positron tries to start Resources/bin/R + let resources_link = target_dir.join("Resources"); + if !resources_link.exists() { + debug!("Creating Resources -> . symlink in {}", target_dir.display()); + symlink(".", &resources_link)?; + } + + let fc_cache = target_dir.join("bin").join("fc-cache"); + run_fc_cache(&fc_cache); + + if let Err(err) = std::fs::remove_dir_all(&tmp) { + OUTPUT.warn(&format!( + "Failed to remove temporary directory {}: {}", + tmp.display(), + err.to_string() + )); + warn!( + "Failed to remove temporary directory {}: {}", + tmp.display(), + err.to_string() + ); + } + + Ok(()) +} + pub fn sc_rm(args: &ArgMatches) -> Result<(), Box> { - escalate("removing R versions")?; + if get_mode()? == crate::utils::Mode::Admin { + escalate("removing R versions")?; + } let vers = args.get_many::("version"); if vers.is_none() { return Ok(()); @@ -328,7 +632,7 @@ pub fn sc_rm(args: &ArgMatches) -> Result<(), Box> { } } - let rroot = get_r_root(); + let rroot = get_r_root()?; let dir = Path::new(&rroot); let dir = dir.join(&ver); OUTPUT.status(&format!("Removing {}", dir.display())); @@ -350,13 +654,27 @@ pub fn sc_rm(args: &ArgMatches) -> Result<(), Box> { sc_system_make_links()?; + if get_mode()? == crate::utils::Mode::User && sc_get_list()?.is_empty() { + if let Err(e) = remove_rstudio_which_r_plist() { + OUTPUT.warn(&format!("Could not remove RSTUDIO_WHICH_R LaunchAgent: {}", e)); + warn!("Could not remove RSTUDIO_WHICH_R LaunchAgent: {}", e); + } + } + Ok(()) } pub fn sc_system_make_links() -> Result<(), Box> { - escalate("making R-* quick links")?; + let binary_dir = get_binary_dir()?; + let mode = get_mode()?; + if mode == crate::utils::Mode::Admin && + access(binary_dir.as_str(), AccessFlags::W_OK).is_err() + { + escalate("making R-* quick links")?; + } + check_local_bin_path()?; let vers = sc_get_list()?; - let rroot = get_r_root(); + let rroot = get_r_root()?; let base = Path::new(&rroot); OUTPUT.status("Updating R-* quick links (as needed)"); @@ -365,9 +683,16 @@ pub fn sc_system_make_links() -> Result<(), Box> { // https://github.com/r-lib/rig/issues/197 let old_umask = umask(Mode::from_bits(0o022).unwrap()); + let binpath = if mode == crate::utils::Mode::Admin { + "Resources/bin/R" + } else { + "bin/R" + }; + // Create new links + debug!("Creating quick links for installed versions"); for ver in vers { - if !is_orthogonal(&ver)? { + if mode == crate::utils::Mode::Admin && !is_orthogonal(&ver)? { OUTPUT.warn(&format!( "Refusing to create quick link for non-orthogonal R version: {}.\n Call `rig system make-orthogonal` to fix this.", ver @@ -378,8 +703,8 @@ pub fn sc_system_make_links() -> Result<(), Box> { ); continue; } - let linkfile = Path::new("/usr/local/bin/").join("R-".to_string() + &ver); - let target = base.join(&ver).join("Resources/bin/R"); + let linkfile = Path::new(&binary_dir).join("R-".to_string() + &ver); + let target = base.join(&ver).join(binpath); if !linkfile.exists() { debug!("Adding {} -> {}", linkfile.display(), target.display()); match symlink(&target, &linkfile) { @@ -408,7 +733,8 @@ pub fn sc_system_make_links() -> Result<(), Box> { umask(old_umask); // Remove dangling links - let paths = std::fs::read_dir("/usr/local/bin")?; + debug!("Cleaning up dangling quick links"); + let paths = std::fs::read_dir(&binary_dir)?; let re = Regex::new("^R-[0-9]+[.][0-9]+")?; let re2 = re_alias(); for file in paths { @@ -457,7 +783,8 @@ pub fn re_alias() -> Regex { pub fn find_aliases() -> Result, Box> { debug!("Finding existing aliases"); - let paths = std::fs::read_dir("/usr/local/bin")?; + let binary_dir = get_binary_dir()?; + let paths = std::fs::read_dir(&binary_dir)?; let re = re_alias(); let mut result: Vec = vec![]; @@ -503,26 +830,50 @@ pub fn find_aliases() -> Result, Box> { // /Library/Frameworks/R.framework/Versions/4.2-arm64/Resources/bin/R -> // 4.2-arm64 fn version_from_link(pb: PathBuf) -> Option { - let osver = match pb - .parent() - .and_then(|x| x.parent()) - .and_then(|x| x.parent()) - .and_then(|x| x.file_name()) - { - None => None, - Some(s) => Some(s.to_os_string()), + // Strip the trailing /bin/R + let p = match pb.parent().and_then(|x| x.parent()) { + None => { + debug!("Path {} is too short to contain /bin/R", pb.display()); + return None; + } + Some(p) => p, }; - let s = match osver { - None => None, - Some(os) => os.into_string().ok(), + // Strip a trailing Resources directory, if present + let p = if p.file_name().and_then(|s| s.to_str()) == Some("Resources") { + match p.parent() { + None => { + debug!("Path {} has no parent above Resources", pb.display()); + return None; + } + Some(p) => p, + } + } else { + p }; - s + // The last remaining directory is the version + let ver = match p.file_name() { + None => { + debug!("Cannot extract version directory from {}", pb.display()); + return None; + } + Some(s) => s.to_os_string(), + }; + + match ver.into_string() { + Ok(s) => Some(s), + Err(_) => { + debug!("Version directory in {} is not valid UTF-8", pb.display()); + None + } + } } pub fn sc_system_allow_core_dumps(args: &ArgMatches) -> Result<(), Box> { - escalate("updating code signature of R and /cores permissions")?; + if get_mode()? == crate::utils::Mode::Admin { + escalate("updating code signature of R and /cores permissions")?; + } sc_system_allow_debugger(args)?; OUTPUT.status("Updating permissions of /cores"); info!("Updating permissions of /cores"); @@ -531,7 +882,9 @@ pub fn sc_system_allow_core_dumps(args: &ArgMatches) -> Result<(), Box Result<(), Box> { - escalate("updating code signature of R")?; + if get_mode()? == crate::utils::Mode::Admin { + escalate("updating code signature of R")?; + } let all = args.get_flag("all"); let vers = args.get_many::("version"); @@ -548,9 +901,8 @@ pub fn sc_system_allow_debugger(args: &ArgMatches) -> Result<(), Box> for ver in vers { let ver = check_installed(&ver)?; let path = PathBuf::new() - .join(get_r_root()) - .join(ver.as_str()) - .join("Resources/bin/exec/R"); + .join(get_r_root()?) + .join(get_r_exec_binpath()?.replace("{}", &ver)); update_entitlements(path)?; } @@ -558,14 +910,13 @@ pub fn sc_system_allow_debugger(args: &ArgMatches) -> Result<(), Box> } pub fn sc_system_allow_debugger_rstudio(_args: &ArgMatches) -> Result<(), Box> { - let rsess = PathBuf::new().join("/Applications/RStudio.app/Contents/MacOS/rsession"); - - if !rsess.exists() { + if !is_rstudio_installed() { OUTPUT.error("RStudio is not installed, at least not in /Applications/RStudio.app"); error!("RStudio is not installed, at least not in /Applications/RStudio.app"); bail!("RStudio is not installed, at least not in /Applications/RStudio.app"); } + let rsess = PathBuf::new().join("/Applications/RStudio.app/Contents/MacOS/rsession"); update_entitlements(rsess)?; let rsessarm64 = PathBuf::new().join("/Applications/RStudio.app/Contents/MacOS/rsession-arm64"); @@ -679,6 +1030,9 @@ pub fn update_entitlements(path: PathBuf) -> Result<(), Box> { } pub fn sc_system_make_orthogonal(args: &ArgMatches) -> Result<(), Box> { + if get_mode()? == crate::utils::Mode::User { + return Ok(()); + } escalate("updating the R installations")?; let vers = args.get_many::("version"); if vers.is_none() { @@ -718,7 +1072,7 @@ fn system_make_orthogonal(vers: Option>) -> Result<(), Box>) -> Result<(), Box Result> { - let base = Path::new(&get_r_root()).join(&ver); + if get_mode()? == crate::utils::Mode::User { + return Ok(true); + } + let base = Path::new(&get_r_root()?).join(&ver); let re = Regex::new("R[.]framework/Resources")?; let rfile = base.join("Resources/bin/R"); let lines = read_lines(&rfile)?; @@ -767,6 +1124,9 @@ fn make_orthogonal_(base: &Path, ver: &str) -> Result<(), Box> { } pub fn sc_system_fix_permissions(args: &ArgMatches) -> Result<(), Box> { + if get_mode()? == crate::utils::Mode::User { + return Ok(()); + } escalate("changing system library permissions")?; let vers = args.get_many::("version"); if vers.is_none() { @@ -781,6 +1141,9 @@ pub fn sc_system_fix_permissions(args: &ArgMatches) -> Result<(), Box } fn system_fix_permissions(vers: Option>) -> Result<(), Box> { + if get_mode()? == crate::utils::Mode::User { + return Ok(()); + } let vers = match vers { Some(x) => x, None => sc_get_list()?, @@ -791,7 +1154,7 @@ fn system_fix_permissions(vers: Option>) -> Result<(), Box>) -> Result<(), Box>) -> Result<(), Box Result<(), Box> { + if get_mode()? == crate::utils::Mode::User { + return Ok(()); + } escalate("forgetting R versions")?; let out = Command::new("sh") .args(["-c", "pkgutil --pkgs | grep -i r-project | grep -v clang"]) @@ -878,7 +1244,9 @@ pub fn sc_system_forget() -> Result<(), Box> { } pub fn sc_system_no_openmp(args: &ArgMatches) -> Result<(), Box> { - escalate("updating R compiler configuration")?; + if get_mode()? == crate::utils::Mode::Admin { + escalate("updating R compiler configuration")?; + } let vers = args.get_many::("version"); if vers.is_none() { system_no_openmp(None) @@ -900,8 +1268,9 @@ fn system_no_openmp(vers: Option>) -> Result<(), Box> { for ver in vers { let ver = check_installed(&ver)?; - let path = Path::new(&get_r_root()).join(ver.as_str()); - let makevars = path.join("Resources/etc/Makeconf".to_string()); + let makevars = Path::new(&get_r_root()?) + .join(get_r_etc_path()?.replace("{}", &ver)) + .join("Makeconf"); if !makevars.exists() { continue; } @@ -909,9 +1278,9 @@ fn system_no_openmp(vers: Option>) -> Result<(), Box> { match replace_in_file(&makevars, &re, "") { Ok(_) => {} Err(err) => { - OUTPUT.error(&format!("Failed to update {}: {}", path.display(), err)); - error!("Failed to update {}: {}", path.display(), err); - bail!("Failed to update {}: {}", path.display(), err); + OUTPUT.error(&format!("Failed to update {}: {}", makevars.display(), err)); + error!("Failed to update {}: {}", makevars.display(), err); + bail!("Failed to update {}: {}", makevars.display(), err); } }; } @@ -964,7 +1333,9 @@ pub fn sc_rstudio_( ver ) } - let path = "RSTUDIO_WHICH_R=".to_string() + &get_r_root() + "/" + &ver + "/Resources/R"; + let rbin = Path::new(&get_r_root()?) + .join(get_r_binpath()?.replace("{}", &ver)); + let path = "RSTUDIO_WHICH_R=".to_string() + &rbin.to_string_lossy(); args.append(&mut osvec!["--env", &path]); } @@ -1006,10 +1377,11 @@ pub fn absolute_path(path: impl AsRef) -> Result> pub fn sc_set_default(ver: &str) -> Result<(), Box> { let ver = check_installed(&ver.to_string())?; + let cur = get_r_current()?; // Maybe it does not exist, ignore error here - std::fs::remove_file(R_CUR).ok(); - let path = Path::new(&get_r_root()).join(ver); - match std::os::unix::fs::symlink(&path, R_CUR) { + std::fs::remove_file(&cur).ok(); + let path = Path::new(&get_r_root()?).join(&ver); + match std::os::unix::fs::symlink(&path, &cur) { Ok(_) => {} Err(_) => { let msg = "Could not change the default R version. :( To be able to\n \ @@ -1021,34 +1393,40 @@ pub fn sc_set_default(ver: &str) -> Result<(), Box> { } }; - let r = Path::new("/usr/local/bin/R"); + check_local_bin_path()?; + let binary_dir = get_binary_dir()?; + + let r = Path::new(&binary_dir).join("R"); if !r.exists() { debug!("Creating {}", r.display()); - let tgt = Path::new("/Library/Frameworks/R.framework/Resources/bin/R"); + let tgt = Path::new(&get_r_default_bindir()?).join("R"); match std::os::unix::fs::symlink(&tgt, &r) { Err(e) => { OUTPUT.warn(&format!( - "Cannot create missing /usr/local/bin/R: {}", + "Cannot create missing {}/R: {}", + binary_dir, e.to_string() )); - warn!("Cannot create missing /usr/local/bin/R: {}", e.to_string()) + warn!("Cannot create missing {}/R: {}", binary_dir, e.to_string()) } _ => {} }; } - let rscript = Path::new("/usr/local/bin/Rscript"); + let rscript = Path::new(&binary_dir).join("Rscript"); if !rscript.exists() { debug!("Creating {}", rscript.display()); - let tgt = Path::new("/Library/Frameworks/R.framework/Resources/bin/Rscript"); + let tgt = Path::new(&get_r_default_bindir()?).join("Rscript"); match std::os::unix::fs::symlink(&tgt, &rscript) { Err(e) => { OUTPUT.warn(&format!( - "Cannot create missing /usr/local/bin/Rscript: {}", + "Cannot create missing {}/Rscript: {}", + binary_dir, e.to_string() )); warn!( - "Cannot create missing /usr/local/bin/Rscript: {}", + "Cannot create missing {}/Rscript: {}", + binary_dir, e.to_string() ) } @@ -1056,20 +1434,192 @@ pub fn sc_set_default(ver: &str) -> Result<(), Box> { }; } + if get_mode()? == crate::utils::Mode::User { + if let Err(e) = ensure_rstudio_which_r_plist() { + OUTPUT.warn(&format!("Could not register default R version in RStudio: {}", e)); + warn!("Could not install RSTUDIO_WHICH_R LaunchAgent: {}", e); + } + if let Err(e) = ensure_positron_custom_root_folders() { + OUTPUT.warn(&format!("Could not register rig R versions in Positron: {}", e)); + warn!("Could not update Positron settings: {}", e); + } + } + + Ok(()) +} + +fn ensure_rstudio_which_r_plist() -> Result<(), Box> { + if !is_rstudio_installed() { + return Ok(()); + } + + let plist_path = rstudio_which_r_plist_path()?; + + if Path::new(&plist_path).exists() { + return Ok(()); + } + + let rbin = Path::new(&get_r_default_bindir()?).join("R"); + let rbin_str = rbin.to_string_lossy(); + + let plist = format!( + r#" + + + + Label + io.r-lib.rig.rstudio-which-r + ProgramArguments + + /bin/launchctl + setenv + RSTUDIO_WHICH_R + {rbin_str} + + RunAtLoad + + + +"# + ); + + let plist_dir = Path::new(&plist_path).parent().unwrap(); + std::fs::create_dir_all(plist_dir)?; + std::fs::write(&plist_path, plist)?; + info!("Installed LaunchAgent {}", plist_path); + + let out = Command::new("launchctl") + .args(["load", &plist_path]) + .output()?; + if !out.status.success() { + let msg = format!( + "Could not register default R version in RStudio: launchctl load {} failed: {}", + plist_path, + String::from_utf8_lossy(&out.stderr) + ); + OUTPUT.error(&msg); + error!("{}", msg); + bail!(msg); + } + + OUTPUT.success("Registered default R version in RStudio"); + + Ok(()) +} + +fn ensure_positron_custom_root_folders() -> Result<(), Box> { + if let Some(val) = crate::config::get_global_config_value("positron-setup")? { + if val == "false" { + debug!("Skipping Positron setup (positron-setup=false in rig config)"); + return Ok(()); + } + } + + let home = std::env::var("HOME")?; + let positron_dir = format!("{}/Library/Application Support/Positron", home); + if !Path::new(&positron_dir).exists() { + debug!("Skipping Positron setup; Positron not found"); + return Ok(()); + } + let settings_path_str = format!("{}/User/settings.json", positron_dir); + let settings_path = Path::new(&settings_path_str); + let r_root = get_r_root()?; + const KEY: &str = "positron.r.customRootFolders"; + + let mut settings: serde_json::Value = if settings_path.exists() { + let contents = std::fs::read_to_string(settings_path)?; + serde_json::from_str(&contents)? + } else { + serde_json::Value::Object(serde_json::Map::new()) + }; + + let obj = settings + .as_object_mut() + .ok_or_else(|| SimpleError::new("Positron settings.json is not a JSON object"))?; + + match obj.get_mut(KEY) { + Some(serde_json::Value::Array(arr)) => { + // Already contains our path — nothing to do + if arr.iter().any(|v| v.as_str() == Some(r_root.as_str())) { + return Ok(()); + } + // Append our path to the existing list + arr.push(serde_json::Value::String(r_root.clone())); + OUTPUT.success("Registered rig R versions in Positron"); + info!("Appended \"{}\" to Positron setting '{}'", r_root, KEY); + } + Some(other) => { + // Unexpected type — leave it alone and inform + info!( + "Positron setting '{}' is not an array ({}); not modifying", + KEY, other + ); + return Ok(()); + } + None => { + obj.insert(KEY.to_string(), serde_json::json!([r_root])); + OUTPUT.success("Registered rig R versions in Positron"); + info!("Set Positron setting '{}' = [\"{}\"]", KEY, r_root); + } + } + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?; + + Ok(()) +} + +fn is_rstudio_installed() -> bool { + Path::new("/Applications/RStudio.app/Contents/MacOS/rsession").exists() +} + +fn rstudio_which_r_plist_path() -> Result> { + let home = std::env::var("HOME")?; + Ok(format!( + "{}/Library/LaunchAgents/io.r-lib.rig.rstudio-which-r.plist", + home + )) +} + +fn remove_rstudio_which_r_plist() -> Result<(), Box> { + let plist_path = rstudio_which_r_plist_path()?; + + if !Path::new(&plist_path).exists() { + return Ok(()); + } + + let out = Command::new("launchctl") + .args(["unload", &plist_path]) + .output()?; + if !out.status.success() { + let msg = format!( + "launchctl unload {} failed: {}", + plist_path, + String::from_utf8_lossy(&out.stderr) + ); + OUTPUT.error(&msg); + error!("{}", msg); + bail!(msg); + } + + std::fs::remove_file(&plist_path)?; + info!("Removed LaunchAgent {}", plist_path); + Ok(()) } pub fn sc_get_default() -> Result, Box> { - read_version_link(R_CUR) + read_version_link(&get_r_current()?) } pub fn sc_get_list() -> Result, Box> { let mut vers = Vec::new(); - if !Path::new(&get_r_root()).exists() { + if !Path::new(&get_r_root()?).exists() { return Ok(vers); } - let paths = std::fs::read_dir(get_r_root())?; + let paths = std::fs::read_dir(get_r_root()?)?; for de in paths { let path = de?.path(); @@ -1088,7 +1638,9 @@ pub fn sc_get_list() -> Result, Box> { } // If there is no Resources/bin/R, then this is not an R installation let rbin = path.join("Resources").join("bin").join("R"); - if !rbin.exists() { + let rbin2 = path.join("bin").join("R"); + if !rbin.exists() && !rbin2.exists() { + debug!("Skipping {}, no R binary found at {} or {}", path.display(), rbin.display(), rbin2.display()); continue; } @@ -1233,24 +1785,23 @@ fn extract_pkg_version(filename: &OsStr) -> Result> pub fn get_r_binary(rver: &str) -> Result> { debug!("Finding R binary for R {}", rver); - let bin = Path::new(&get_r_root()).join(rver).join("Resources/R"); + let bin = Path::new(&get_r_root()?) + .join(get_r_binpath()?.replace("{}", rver)); debug!("R {} binary is at {}", rver, bin.display()); Ok(bin) } #[allow(dead_code)] pub fn get_system_renviron(rver: &str) -> Result> { - let renviron = Path::new(&get_r_root()) + let renviron = Path::new(&get_r_root()?) .join(rver) .join("Resources/etc/Renviron"); Ok(renviron) } pub fn get_system_profile(rver: &str) -> Result> { - let profile = Path::new(&get_r_root()) - .join(rver) - .join("Resources/library/base/R/Rprofile"); - Ok(profile) + let profile = get_r_base_profile()?.replace("{}",rver); + Ok(PathBuf::from(&get_r_root()?).join(profile)) } pub fn is_arm64_machine() -> bool { @@ -1275,3 +1826,406 @@ pub fn is_arm64_machine() -> bool { false } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn write_fake_r(source_dir: &Path, content: &str) { + let bindir = source_dir.join("bin"); + fs::create_dir_all(&bindir).unwrap(); + fs::write(bindir.join("R"), content).unwrap(); + } + + #[test] + fn patch_user_r_script_replaces_path_and_inserts_self_locating_lines() { + let dir = tempfile::tempdir().unwrap(); + write_fake_r( + dir.path(), + "#!/bin/sh\n\ + R_HOME_DIR=/Library/Frameworks/R.framework/Resources\n\ + exec \"${R_HOME_DIR}/bin/exec/R\" \"$@\"\n", + ); + let home_dir = Path::new("/opt/r/4.6-arm64"); + + patch_user_r_script(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("bin/R")).unwrap(); + + // Original path is replaced with home_dir. + assert!(!content.contains("R_HOME_DIR=/Library/Frameworks/R.framework/Resources")); + assert!(content.contains("R_HOME_DIR=/opt/r/4.6-arm64")); + // Self-locating override added with $-escapes correctly applied. + assert!(content.contains( + "R_HOME_DIR=$(cd \"$(dirname \"$(realpath \"$0\")\")/..\" && pwd -P)" + )); + // DYLD line added; ${R_HOME_DIR} stays literal (not eaten as a regex group). + assert!(content.contains("export DYLD_LIBRARY_PATH=\"${R_HOME_DIR}/lib\"")); + // Trailing content survives. + assert!(content.contains("exec \"${R_HOME_DIR}/bin/exec/R\" \"$@\"")); + } + + #[test] + fn patch_user_r_script_preserves_order() { + let dir = tempfile::tempdir().unwrap(); + write_fake_r(dir.path(), "before\nR_HOME_DIR=/orig\nafter\n"); + let home_dir = Path::new("/home/x"); + + patch_user_r_script(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("bin/R")).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + + assert_eq!(lines.len(), 5); + assert_eq!(lines[0], "before"); + assert_eq!(lines[1], "R_HOME_DIR=/home/x"); + assert!(lines[2].starts_with("R_HOME_DIR=$(cd ")); + assert!(lines[3].starts_with("export DYLD_LIBRARY_PATH=")); + assert_eq!(lines[4], "after"); + } + + #[test] + fn patch_user_r_script_only_matches_assignment_lines() { + // References to $R_HOME_DIR that aren't assignments must not be touched. + let dir = tempfile::tempdir().unwrap(); + write_fake_r( + dir.path(), + "echo \"$R_HOME_DIR\"\nR_HOME_DIR=/orig\nuse $R_HOME_DIR here\n", + ); + let home_dir = Path::new("/opt/r"); + + patch_user_r_script(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("bin/R")).unwrap(); + + assert_eq!(content.matches("echo \"$R_HOME_DIR\"").count(), 1); + assert_eq!(content.matches("use $R_HOME_DIR here").count(), 1); + + // Original /orig is gone; replaced with the new home_dir. + assert!(!content.contains("R_HOME_DIR=/orig")); + assert!(content.contains("R_HOME_DIR=/opt/r")); + + let assignment_count = content + .lines() + .filter(|l| l.starts_with("R_HOME_DIR=")) + .count(); + assert_eq!(assignment_count, 2); + } + + #[test] + fn patch_user_r_script_escapes_dollar_in_home_dir() { + // A `$` in the install path must not be interpreted as a regex backref. + let dir = tempfile::tempdir().unwrap(); + write_fake_r(dir.path(), "R_HOME_DIR=/orig\n"); + let home_dir = Path::new("/weird/$path"); + + patch_user_r_script(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("bin/R")).unwrap(); + + assert!(content.contains("R_HOME_DIR=/weird/$path")); + } + + #[test] + fn patch_user_r_script_errors_when_script_missing() { + let dir = tempfile::tempdir().unwrap(); + let home_dir = Path::new("/opt/r"); + assert!(patch_user_r_script(dir.path(), home_dir).is_err()); + } + + fn write_fake_makeconf(source_dir: &Path, content: &str) { + let etcdir = source_dir.join("etc"); + fs::create_dir_all(&etcdir).unwrap(); + fs::write(etcdir.join("Makeconf"), content).unwrap(); + } + + fn write_fake_renviron(source_dir: &Path, content: &str) { + let etcdir = source_dir.join("etc"); + fs::create_dir_all(&etcdir).unwrap(); + fs::write(etcdir.join("Renviron"), content).unwrap(); + } + + fn write_fake_fonts_conf(source_dir: &Path, content: &str) { + let fontsdir = source_dir.join("fontconfig").join("fonts"); + fs::create_dir_all(&fontsdir).unwrap(); + fs::write(fontsdir.join("fonts.conf"), content).unwrap(); + } + + fn write_fake_libpc(source_dir: &Path, content: &str) { + let pcdir = source_dir.join("lib").join("pkgconfig"); + fs::create_dir_all(&pcdir).unwrap(); + fs::write(pcdir.join("libR.pc"), content).unwrap(); + } + + const STUB_LIBPC: &str = "rincludedir=/x\n"; + + #[test] + fn patch_user_scripts_replaces_libr_line() { + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf( + dir.path(), + "CC = clang\n\ + LIBR = -F/Library/Frameworks/R.framework/.. -framework R\n\ + LDFLAGS = -L/usr/local/lib\n", + ); + write_fake_renviron(dir.path(), "R_QPDF=qpdf\n"); + write_fake_fonts_conf(dir.path(), "\n"); + write_fake_libpc(dir.path(), STUB_LIBPC); + let home_dir = Path::new("/opt/r"); + + patch_user_scripts(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("etc/Makeconf")).unwrap(); + + // Old LIBR is gone; new one in place with literal $(R_HOME). + assert!(!content.contains("-framework R")); + assert!(content.contains("LIBR = -L\"$(R_HOME)/lib\" -lR")); + // Surrounding lines untouched. + assert!(content.contains("CC = clang")); + assert!(content.contains("LDFLAGS = -L/usr/local/lib")); + } + + #[test] + fn patch_user_scripts_matches_various_libr_whitespace() { + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf(dir.path(), "LIBR=-framework R\n"); + write_fake_renviron(dir.path(), "R_QPDF=qpdf\n"); + write_fake_fonts_conf(dir.path(), "\n"); + write_fake_libpc(dir.path(), STUB_LIBPC); + let home_dir = Path::new("/opt/r"); + + patch_user_scripts(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("etc/Makeconf")).unwrap(); + assert!(content.contains("LIBR = -L\"$(R_HOME)/lib\" -lR")); + } + + #[test] + fn patch_user_scripts_does_not_touch_similar_names() { + // Variables that merely *start* with LIBR (e.g. LIBRARY) must not match. + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf( + dir.path(), + "LIBRARY = something\n\ + LIBR_FOO = other\n\ + LIBR = -framework R\n", + ); + write_fake_renviron( + dir.path(), + "R_QPDFEXT=foo\n\ + R_QPDF_X=bar\n\ + R_QPDF=qpdf\n", + ); + write_fake_fonts_conf(dir.path(), "\n"); + write_fake_libpc(dir.path(), STUB_LIBPC); + let home_dir = Path::new("/opt/r"); + + patch_user_scripts(dir.path(), home_dir).unwrap(); + let mk = fs::read_to_string(dir.path().join("etc/Makeconf")).unwrap(); + let rv = fs::read_to_string(dir.path().join("etc/Renviron")).unwrap(); + + assert!(mk.contains("LIBRARY = something")); + assert!(mk.contains("LIBR_FOO = other")); + assert_eq!(mk.lines().filter(|l| l.starts_with("LIBR ")).count(), 1); + + assert!(rv.contains("R_QPDFEXT=foo")); + assert!(rv.contains("R_QPDF_X=bar")); + assert_eq!( + rv.lines().filter(|l| l.starts_with("R_QPDF=")).count(), + 1 + ); + } + + #[test] + fn patch_user_scripts_replaces_r_qpdf_line() { + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf(dir.path(), "LIBR = -framework R\n"); + write_fake_renviron( + dir.path(), + "R_PAPERSIZE=letter\n\ + R_QPDF=/usr/local/bin/qpdf\n\ + R_BROWSER=open\n", + ); + write_fake_fonts_conf(dir.path(), "\n"); + write_fake_libpc(dir.path(), STUB_LIBPC); + let home_dir = Path::new("/opt/r"); + + patch_user_scripts(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("etc/Renviron")).unwrap(); + + // Old value is gone; new one in place with home_dir baked in + // (R does not expand $R_HOME when reading Renviron). + assert!(!content.contains("R_QPDF=/usr/local/bin/qpdf")); + assert!(content.contains("R_QPDF=${R_QPDF-/opt/r/bin/qpdf}")); + assert!(!content.contains("${R_HOME}")); + // Surrounding lines untouched. + assert!(content.contains("R_PAPERSIZE=letter")); + assert!(content.contains("R_BROWSER=open")); + } + + #[test] + fn patch_user_scripts_replaces_fontconfig_paths() { + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf(dir.path(), "LIBR = -framework R\n"); + write_fake_renviron(dir.path(), "R_QPDF=qpdf\n"); + write_fake_fonts_conf( + dir.path(), + "/Library/Frameworks/R.framework/Resources/share/fonts\n\ + /Library/Frameworks/R.framework/Resources/var/cache\n\ + untouched\n", + ); + write_fake_libpc(dir.path(), STUB_LIBPC); + let home_dir = Path::new("/opt/r/4.6-arm64"); + + patch_user_scripts(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("fontconfig/fonts/fonts.conf")).unwrap(); + + // Original framework path is gone everywhere it appeared. + assert!(!content.contains("/Library/Frameworks/R.framework/Resources")); + // Replaced with home_dir at every occurrence. + assert!(content.contains("/opt/r/4.6-arm64/share/fonts")); + assert!(content.contains("/opt/r/4.6-arm64/var/cache")); + // Unrelated content unchanged. + assert!(content.contains("untouched")); + } + + #[test] + fn patch_user_scripts_escapes_dollar_in_home_dir_for_fontconfig() { + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf(dir.path(), "LIBR = -framework R\n"); + write_fake_renviron(dir.path(), "R_QPDF=qpdf\n"); + write_fake_fonts_conf( + dir.path(), + "/Library/Frameworks/R.framework/Resources/share/fonts\n", + ); + write_fake_libpc(dir.path(), STUB_LIBPC); + let home_dir = Path::new("/weird/$path"); + + patch_user_scripts(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("fontconfig/fonts/fonts.conf")).unwrap(); + assert!(content.contains("/weird/$path/share/fonts")); + } + + #[test] + fn patch_user_scripts_errors_when_makeconf_missing() { + let dir = tempfile::tempdir().unwrap(); + // Renviron and fonts.conf present, Makeconf missing — should fail on Makeconf. + write_fake_renviron(dir.path(), "R_QPDF=qpdf\n"); + write_fake_fonts_conf(dir.path(), "\n"); + let home_dir = Path::new("/opt/r"); + assert!(patch_user_scripts(dir.path(), home_dir).is_err()); + } + + #[test] + fn patch_user_scripts_errors_when_renviron_missing() { + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf(dir.path(), "LIBR = -framework R\n"); + write_fake_fonts_conf(dir.path(), "\n"); + let home_dir = Path::new("/opt/r"); + assert!(patch_user_scripts(dir.path(), home_dir).is_err()); + } + + #[test] + fn patch_user_scripts_ok_when_fonts_conf_missing() { + // fonts.conf is optional; older R versions don't ship it. + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf(dir.path(), "LIBR = -framework R\n"); + write_fake_renviron(dir.path(), "R_QPDF=qpdf\n"); + write_fake_libpc(dir.path(), STUB_LIBPC); + let home_dir = Path::new("/opt/r"); + assert!(patch_user_scripts(dir.path(), home_dir).is_ok()); + } + + #[test] + fn patch_user_scripts_replaces_libpc_paths_and_rincludedir() { + // Realistic libR.pc body: framework paths in `rhome=` should become + // home_dir, and `rincludedir=` should become a pkg-config var ref. + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf(dir.path(), "LIBR = -framework R\n"); + write_fake_renviron(dir.path(), "R_QPDF=qpdf\n"); + write_fake_fonts_conf(dir.path(), "\n"); + write_fake_libpc( + dir.path(), + "prefix=/Library/Frameworks/R.framework/Resources\n\ + exec_prefix=${prefix}\n\ + libdir=${exec_prefix}/lib\n\ + rhome=/Library/Frameworks/R.framework/Versions/4.5-arm64/Resources\n\ + rincludedir=/Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/include\n", + ); + let home_dir = Path::new("/opt/r/4.5-arm64"); + + patch_user_scripts(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("lib/pkgconfig/libR.pc")).unwrap(); + + // rhome's framework path is rewritten to home_dir. + assert!(content.contains("rhome=/opt/r/4.5-arm64\n")); + // rincludedir line becomes the pkg-config var form (literal `${rhome}`). + assert!(content.contains("rincludedir=${rhome}/include")); + assert!(!content + .contains("rincludedir=/Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/include")); + // Lines without the versioned framework path are left alone. + assert!(content.contains("prefix=/Library/Frameworks/R.framework/Resources")); + assert!(content.contains("exec_prefix=${prefix}")); + assert!(content.contains("libdir=${exec_prefix}/lib")); + // No leftover versioned framework path anywhere. + assert!(!content.contains("/Library/Frameworks/R.framework/Versions/")); + } + + #[test] + fn patch_user_scripts_libpc_handles_various_versions() { + // The version segment is matched as `[^/]+`, so any value works. + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf(dir.path(), "LIBR = -framework R\n"); + write_fake_renviron(dir.path(), "R_QPDF=qpdf\n"); + write_fake_fonts_conf(dir.path(), "\n"); + write_fake_libpc( + dir.path(), + "rhome=/Library/Frameworks/R.framework/Versions/4.6/Resources\n\ + rincludedir=/Library/Frameworks/R.framework/Versions/4.6/Resources/include\n", + ); + let home_dir = Path::new("/opt/r/4.6"); + + patch_user_scripts(dir.path(), home_dir).unwrap(); + let content = fs::read_to_string(dir.path().join("lib/pkgconfig/libR.pc")).unwrap(); + assert!(content.contains("rhome=/opt/r/4.6\n")); + assert!(content.contains("rincludedir=${rhome}/include")); + } + + #[test] + fn patch_user_scripts_ok_when_libpc_missing() { + // libR.pc is optional; older R versions don't ship it. + let dir = tempfile::tempdir().unwrap(); + write_fake_makeconf(dir.path(), "LIBR = -framework R\n"); + write_fake_renviron(dir.path(), "R_QPDF=qpdf\n"); + write_fake_fonts_conf(dir.path(), "\n"); + let home_dir = Path::new("/opt/r"); + assert!(patch_user_scripts(dir.path(), home_dir).is_ok()); + } + + #[test] + fn remove_user_dyld_shadows_drops_shadowing_dylibs_and_keeps_others() { + let dir = tempfile::tempdir().unwrap(); + let lib = dir.path().join("lib"); + fs::create_dir_all(&lib).unwrap(); + let shadows = ["libc++.1.dylib", "libc++abi.1.dylib", "libunwind.1.dylib"]; + for name in shadows { + fs::write(lib.join(name), b"shadow").unwrap(); + } + let keep = ["libR.dylib", "libomp.dylib", "libgfortran.3.dylib"]; + for name in keep { + fs::write(lib.join(name), b"keep").unwrap(); + } + + remove_user_dyld_shadows(dir.path()).unwrap(); + + for name in shadows { + assert!(!lib.join(name).exists(), "expected {} to be removed", name); + } + for name in keep { + assert!(lib.join(name).exists(), "expected {} to be preserved", name); + } + } + + #[test] + fn remove_user_dyld_shadows_is_ok_when_dylibs_are_absent() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("lib")).unwrap(); + // No shadow dylibs present (e.g. R 4.0+); call must succeed. + remove_user_dyld_shadows(dir.path()).unwrap(); + } +} diff --git a/src/main.rs b/src/main.rs index 2aab850d..f866e879 100644 --- a/src/main.rs +++ b/src/main.rs @@ -209,6 +209,21 @@ fn main_() -> i32 { unset_r_envvars(); + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + if args.get_flag("user") { + if let Err(e) = utils::set_mode(utils::Mode::User) { + error!("{}", e); + return 1; + } + } else if args.get_flag("admin") { + if let Err(e) = utils::set_mode(utils::Mode::Admin) { + error!("{}", e); + return 1; + } + } + } + #[cfg(target_os = "linux")] set_cert_envvar(); @@ -246,6 +261,7 @@ fn main__(args: &ArgMatches) -> Result> { Some(("resolve", sub)) => sc_resolve(sub, args)?, Some(("rstudio", sub)) => sc_rstudio(sub)?, Some(("library", sub)) => sc_library(sub, args)?, + Some(("config", sub)) => crate::config::sc_config(sub, args)?, Some(("sysreqs", sub)) => sc_sysreqs(sub, args)?, Some(("available", sub)) => sc_available(sub, args)?, Some(("run", sub)) => retval = sc_run(sub, args)?, diff --git a/src/platform.rs b/src/platform.rs index 4bd596ff..1bf30783 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -283,9 +283,13 @@ pub fn platform_to_pkg_type(platform: &OsVersion, r_version: &str) -> Option = Mutex::new(()); #[test] fn test_detect_platform_rig_platform_short() { + let _guard = RIG_PLATFORM_LOCK.lock().unwrap(); // "ubuntu-24.04" should be treated as a linux platform shorthand std::env::set_var("RIG_PLATFORM", "ubuntu-24.04"); let result = detect_platform().unwrap(); @@ -298,6 +302,7 @@ mod tests { #[test] fn test_detect_platform_rig_platform_prefixed() { + let _guard = RIG_PLATFORM_LOCK.lock().unwrap(); // "linux-ubuntu-22.04" explicit form std::env::set_var("RIG_PLATFORM", "linux-ubuntu-22.04"); let result = detect_platform().unwrap(); diff --git a/src/repos/repos_list.rs b/src/repos/repos_list.rs index 4e02dc25..d99645cb 100644 --- a/src/repos/repos_list.rs +++ b/src/repos/repos_list.rs @@ -18,8 +18,8 @@ pub fn sc_repos_list( }; let all = args.get_flag("all"); - let root: String = get_r_root_for(&rver); - let repositories = root.clone() + "/" + &R_ETC_PATH.replace("{}", &version_dir_key(&rver)) + "/repositories"; + let root: String = get_r_root_for(&rver)?; + let repositories = root.clone() + "/" + &get_r_etc_path()?.replace("{}", &version_dir_key(&rver)) + "/repositories"; let mut repos = read_repositories_file(&repositories)?.data; if !all { diff --git a/src/repos/setup.rs b/src/repos/setup.rs index 603ff098..aaf38147 100644 --- a/src/repos/setup.rs +++ b/src/repos/setup.rs @@ -100,8 +100,8 @@ pub fn repos_setup(vers: Option>, setup: ReposSetupArgs) -> Result<( for ver in vers { let ver = check_installed(&ver.to_string())?; - let root: String = get_r_root_for(&ver); - let repositories = root.clone() + "/" + &R_ETC_PATH.replace("{}", &version_dir_key(&ver)) + "/repositories"; + let root: String = get_r_root_for(&ver)?; + let repositories = root.clone() + "/" + &get_r_etc_path()?.replace("{}", &version_dir_key(&ver)) + "/repositories"; // if no 'repositories' file, skip. Maybe this happens for very old R versions? if !PathBuf::from(&repositories).exists() { @@ -160,7 +160,7 @@ pub fn repos_setup(vers: Option>, setup: ReposSetupArgs) -> Result<( write_repositories_file(repos, &repositories)?; - let profile = root.clone() + "/" + &get_r_base_profile(&ver); + let profile = root.clone() + "/" + &get_r_base_profile()?.replace("{}", &version_dir_key(&ver)); debug!("Updating R profile at {}", profile); let mut profile_lines = read_lines(&Path::new(&profile))?; @@ -514,8 +514,8 @@ mod tests { } fn get_r_data_common(ver: &str) -> Result> { - let root: String = get_r_root_for(ver); - let statsdesc = root + "/" + &R_SYSLIBPATH.replace("{}", &version_dir_key(ver)) + "/stats/DESCRIPTION"; + let root: String = get_r_root_for(ver)?; + let statsdesc = root + "/" + &get_r_syslibpath()?.replace("{}", &version_dir_key(ver)) + "/stats/DESCRIPTION"; debug!("Getting architectture from {}.", statsdesc); let lines = read_lines(Path::new(&statsdesc))?; let re = Regex::new("^Built:[ ]?")?; diff --git a/src/run.rs b/src/run.rs index 7ba87b58..f444ce98 100644 --- a/src/run.rs +++ b/src/run.rs @@ -77,7 +77,7 @@ pub fn r(version: &str, command: &str) -> Result<(), Box> { #[cfg(target_os = "macos")] fn r_sudo(version: &str, command: &str, user: &User) -> Result<(), Box> { - let rbin = get_r_root_for(version).to_string() + "/" + &R_BINPATH.replace("{}", &version_dir_key(version)); + let rbin = get_r_root_for(version)? + "/" + &get_r_binpath()?.replace("{}", &version_dir_key(version)); let escaped_command = rbin + " --vanilla -s -e \"" + &command.replace("\"", "\\\"").replace("$", "\\$") + "\""; @@ -92,7 +92,7 @@ fn r_sudo(version: &str, command: &str, user: &User) -> Result<(), Box Result<(), Box> { - let rbin = get_r_root_for(version).to_string() + "/" + &R_BINPATH.replace("{}", &version_dir_key(version)); + let rbin = get_r_root_for(version)? + "/" + &get_r_binpath()?.replace("{}", &version_dir_key(version)); let username = user.user.to_string(); let mut args: Vec = vec![username.into()]; @@ -127,7 +127,7 @@ fn r_sudo(version: &str, command: &str, user: &User) -> Result<(), Box Result<(), Box> { - let rbin = get_r_root_for(version).to_string() + "/" + &R_BINPATH.replace("{}", &version_dir_key(version)); + let rbin = get_r_root_for(version)? + "/" + &get_r_binpath()?.replace("{}", &version_dir_key(version)); run( rbin.into(), diff --git a/src/scrun.rs b/src/scrun.rs index c9cc30d0..eccbd452 100644 --- a/src/scrun.rs +++ b/src/scrun.rs @@ -28,7 +28,7 @@ pub fn sc_run(args: &ArgMatches, _mainargs: &ArgMatches) -> Result check_installed(x)?, None => sc_get_default_or_fail()?, }; - let rbin = get_r_root_for(&rver).to_string() + "/" + &R_BINPATH.replace("{}", &version_dir_key(&rver)); + let rbin = get_r_root_for(&rver)? + "/" + &get_r_binpath()?.replace("{}", &version_dir_key(&rver)); let eval = args.get_one::("eval"); let script = args.get_one::("script"); diff --git a/src/utils.rs b/src/utils.rs index 5953c555..9ef292da 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,6 +4,8 @@ use std::ffi::OsString; use std::fs::File; use std::io::{prelude::*, BufReader}; use std::path::{Path, PathBuf}; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use std::sync::OnceLock; use regex::Regex; use sha2::{Digest, Sha256}; @@ -254,6 +256,104 @@ pub fn get_user() -> Result> { }) } +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + User, + Admin, +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn parse_mode(s: &str) -> Option { + match s { + "user" => Some(Mode::User), + "admin" => Some(Mode::Admin), + _ => None, + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +static MODE_CACHE: OnceLock = OnceLock::new(); + +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub fn set_mode(mode: Mode) -> Result<(), Box> { + match MODE_CACHE.set(mode) { + Ok(()) => Ok(()), + Err(existing) if existing == mode => Ok(()), + Err(existing) => bail!( + "Cannot set mode to {:?}, already set to {:?}", + mode, + existing + ), + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub fn get_mode() -> Result> { + if let Some(cached) = MODE_CACHE.get() { + return Ok(*cached); + } + + let mode = if let Ok(val) = std::env::var("RIG_MODE") { + match parse_mode(&val) { + Some(m) => m, + None => bail!( + "Invalid RIG_MODE value: '{}', expected 'user' or 'admin'", + val + ), + } + } else if let Some(val) = crate::config::get_global_config_value("mode")? { + match parse_mode(&val) { + Some(m) => m, + None => bail!( + "Invalid 'mode' in rig config: '{}', expected 'user' or 'admin'", + val + ), + } + } else { + Mode::Admin + }; + + let _ = MODE_CACHE.set(mode); + Ok(mode) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub fn get_binary_dir() -> Result> { + if let Ok(val) = std::env::var("RIG_BINARY_DIR") { + return Ok(val.trim_end_matches('/').to_string()); + } + + if let Some(val) = crate::config::get_global_config_value("binary-dir")? { + return Ok(val.trim_end_matches('/').to_string()); + } + + if get_mode()? == Mode::User { + let home = std::env::var("HOME")?; + return Ok(format!("{}/.local/bin", home)); + } + + Ok("/usr/local/bin".to_string()) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub fn get_r_install_dir() -> Result, Box> { + if let Ok(val) = std::env::var("RIG_R_INSTALL_DIR") { + return Ok(Some(val.trim_end_matches('/').to_string())); + } + + if let Some(val) = crate::config::get_global_config_value("r-install-dir")? { + return Ok(Some(val.trim_end_matches('/').to_string())); + } + + if get_mode()? == Mode::User { + let home = std::env::var("HOME")?; + return Ok(Some(format!("{}/.local/share/rig/r", home))); + } + + Ok(None) +} + pub fn unset_r_envvars() { let evs = vec![ "R_ARCH", @@ -316,11 +416,343 @@ pub fn create_parent_dir_if_needed(path: &PathBuf) -> Result<(), Box> Ok(()) } +// we use our own rigenv file instead of the standard env file from +// axodotdev/cargo-dist, to work around +// https://github.com/axodotdev/cargo-dist/issues/2390 +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub fn add_local_bin_to_path() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let home = match std::env::var("HOME") { + Ok(x) => x, + Err(_) => bail!("HOME environment variable is not set"), + }; + let home_path = Path::new(&home); + + let local_bin = home_path.join(".local/bin"); + debug!("Ensuring {} exists", local_bin.display()); + std::fs::create_dir_all(&local_bin)?; + + let env_file = local_bin.join("rigenv"); + debug!("Writing rigenv script to {}", env_file.display()); + let content = "\ +#!/bin/sh +# Ensure $HOME/.local/bin is on PATH and ahead of /usr/local/bin so that +# rig-managed binaries take precedence over system binaries. +_rigenv_path=\":${PATH}:\" +_rigenv_local=\"$HOME/.local/bin\" +_rigenv_prefix=\"${_rigenv_path%%:/usr/local/bin:*}\" +case \"${_rigenv_prefix}:\" in + *:\"$_rigenv_local\":*) ;; + *) export PATH=\"$_rigenv_local:$PATH\" ;; +esac +unset _rigenv_path _rigenv_local _rigenv_prefix +"; + std::fs::write(&env_file, content)?; + let mut perms = std::fs::metadata(&env_file)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&env_file, perms)?; + + let source_line = ". \"$HOME/.local/bin/rigenv\""; + + let candidates = [ + home_path.join(".profile"), + home_path.join(".bash_profile"), + home_path.join(".bashrc"), + home_path.join(".zprofile"), + home_path.join(".zshrc"), + ]; + + let mut any_found = false; + for profile in &candidates { + if profile.exists() { + any_found = true; + let content = std::fs::read_to_string(profile)?; + if !content.contains(source_line) { + debug!("Appending source line to {}", profile.display()); + let mut file = std::fs::OpenOptions::new().append(true).open(profile)?; + writeln!(file, "\n{}", source_line)?; + } else { + debug!("Source line already present in {}", profile.display()); + } + } + } + + if !any_found { + let profile = home_path.join(".profile"); + debug!("No existing profile found; creating {}", profile.display()); + std::fs::write(&profile, format!("{}\n", source_line))?; + } + + let fish_dir = home_path.join(".config/fish"); + if fish_dir.exists() { + let fish_confd = fish_dir.join("conf.d"); + std::fs::create_dir_all(&fish_confd)?; + let fish_file = fish_confd.join("rigenv.fish"); + debug!("Writing fish snippet to {}", fish_file.display()); + let fish_content = "\ +# Ensure $HOME/.local/bin is on PATH ahead of /usr/local/bin so that +# rig-managed binaries take precedence over system binaries. +fish_add_path --prepend --move \"$HOME/.local/bin\" +"; + std::fs::write(&fish_file, fish_content)?; + } + + Ok(()) +} + +#[cfg(target_os = "windows")] +pub fn add_local_bin_to_path() -> Result<(), Box> { + Ok(()) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub fn check_local_bin_path() -> Result<(), Box> { + use crate::output::OUTPUT; + use std::sync::atomic::{AtomicBool, Ordering}; + + static ADD_DONE: AtomicBool = AtomicBool::new(false); + static WARN_DONE: AtomicBool = AtomicBool::new(false); + + let binary_dir = get_binary_dir()?; + + let home = match std::env::var("HOME") { + Ok(x) => x, + Err(_) => bail!("HOME environment variable is not set"), + }; + let local_bin = Path::new(&home).join(".local/bin"); + + let path_var = std::env::var("PATH").unwrap_or_default(); + let paths: Vec = std::env::split_paths(&path_var).collect(); + + let local_bin_idx = paths.iter().position(|p| p == &local_bin); + let usr_local_bin_idx = paths.iter().position(|p| p == Path::new("/usr/local/bin")); + debug!( + "PATH check: {} at {:?}, /usr/local/bin at {:?}", + local_bin.display(), + local_bin_idx, + usr_local_bin_idx + ); + + let needs_add = match (local_bin_idx, usr_local_bin_idx) { + (None, _) => true, + (Some(l), Some(u)) if l > u => true, + _ => false, + }; + + if needs_add && !ADD_DONE.swap(true, Ordering::Relaxed) { + debug!("Updating shell profiles to put {} on PATH", local_bin.display()); + add_local_bin_to_path()?; + } else if !needs_add { + debug!("{} is already correctly placed on PATH", local_bin.display()); + } + + let on_path = paths.iter().any(|p| p == Path::new(&binary_dir)); + + if !on_path && !WARN_DONE.swap(true, Ordering::Relaxed) { + let is_local_bin = Path::new(&binary_dir) == local_bin; + if is_local_bin { + let shell = std::env::var("SHELL").unwrap_or_default(); + let is_fish = shell.contains("fish"); + OUTPUT.warn(&format!("{} is not on the PATH.", binary_dir)); + eprintln!(" To add it to the current session, run:"); + if is_fish { + eprintln!(" fish_add_path \"$HOME/.local/bin\" # fish"); + } else { + eprintln!(" . \"$HOME/.local/bin/rigenv\" # bash/zsh/sh"); + eprintln!(" fish_add_path \"$HOME/.local/bin\" # fish"); + } + eprintln!(" New shell sessions will pick it up automatically."); + } else { + OUTPUT.warn(&format!("{} is not on the PATH.", binary_dir)); + } + } + + Ok(()) +} + +#[cfg(target_os = "windows")] +pub fn check_local_bin_path() -> Result<(), Box> { + Ok(()) +} + #[cfg(test)] +#[cfg(any(target_os = "macos", target_os = "linux"))] mod tests { use super::*; - use regex::Regex; - use std::path::Path; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::sync::Mutex; + + // Serialize tests that mutate env vars to avoid races. + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + fn with_temp_home(f: F) { + let dir = tempfile::tempdir().unwrap(); + let _guard = ENV_MUTEX.lock().unwrap(); + unsafe { + std::env::set_var("HOME", dir.path()); + std::env::remove_var("RIG_BINARY_DIR"); + std::env::remove_var("RIG_MODE"); + } + f(dir.path()); + } + + #[test] + fn test_add_local_bin_creates_directory() { + with_temp_home(|home| { + add_local_bin_to_path().unwrap(); + assert!(home.join(".local/bin").is_dir()); + }); + } + + #[test] + fn test_add_local_bin_creates_env_script() { + with_temp_home(|home| { + add_local_bin_to_path().unwrap(); + let env_file = home.join(".local/bin/rigenv"); + assert!(env_file.exists()); + let content = fs::read_to_string(&env_file).unwrap(); + assert!(content.contains("$HOME/.local/bin")); + assert!(content.contains("/usr/local/bin")); + }); + } + + #[test] + fn test_add_local_bin_env_script_is_executable() { + with_temp_home(|home| { + add_local_bin_to_path().unwrap(); + let env_file = home.join(".local/bin/rigenv"); + let perms = fs::metadata(&env_file).unwrap().permissions(); + assert!(perms.mode() & 0o111 != 0); + }); + } + + #[test] + fn test_add_local_bin_creates_profile_when_none_exist() { + with_temp_home(|home| { + add_local_bin_to_path().unwrap(); + let profile = home.join(".profile"); + assert!(profile.exists()); + let content = fs::read_to_string(&profile).unwrap(); + assert!(content.contains(". \"$HOME/.local/bin/rigenv\"")); + }); + } + + #[test] + fn test_add_local_bin_appends_to_existing_bash_profile() { + with_temp_home(|home| { + let bash_profile = home.join(".bash_profile"); + fs::write(&bash_profile, "# existing\n").unwrap(); + add_local_bin_to_path().unwrap(); + let content = fs::read_to_string(&bash_profile).unwrap(); + assert!(content.contains("# existing")); + assert!(content.contains(". \"$HOME/.local/bin/rigenv\"")); + // .profile should not be created since a profile was found + assert!(!home.join(".profile").exists()); + }); + } + + #[test] + fn test_add_local_bin_does_not_duplicate_source_line() { + with_temp_home(|home| { + let profile = home.join(".profile"); + fs::write(&profile, ". \"$HOME/.local/bin/rigenv\"\n").unwrap(); + add_local_bin_to_path().unwrap(); + let content = fs::read_to_string(&profile).unwrap(); + assert_eq!(content.matches(". \"$HOME/.local/bin/rigenv\"").count(), 1); + }); + } + + #[test] + fn test_add_local_bin_refreshes_stale_env_script() { + with_temp_home(|home| { + fs::create_dir_all(home.join(".local/bin")).unwrap(); + let env_file = home.join(".local/bin/rigenv"); + fs::write(&env_file, "stale content").unwrap(); + add_local_bin_to_path().unwrap(); + let content = fs::read_to_string(&env_file).unwrap(); + assert_ne!(content, "stale content"); + assert!(content.contains("$HOME/.local/bin")); + assert!(content.contains("/usr/local/bin")); + }); + } + + #[test] + fn test_add_local_bin_appends_to_all_existing_profiles() { + with_temp_home(|home| { + let zprofile = home.join(".zprofile"); + let bash_profile = home.join(".bash_profile"); + fs::write(&zprofile, "# zsh\n").unwrap(); + fs::write(&bash_profile, "# bash\n").unwrap(); + add_local_bin_to_path().unwrap(); + for p in [&zprofile, &bash_profile] { + let content = fs::read_to_string(p).unwrap(); + assert!(content.contains(". \"$HOME/.local/bin/rigenv\""), "{p:?} missing source line"); + } + }); + } + + #[test] + fn test_add_local_bin_does_not_add_path_line_to_profile() { + with_temp_home(|home| { + add_local_bin_to_path().unwrap(); + let profile = home.join(".profile"); + let content = fs::read_to_string(&profile).unwrap(); + assert!(!content.contains("export PATH=")); + assert!(!content.contains("cargo-dist")); + }); + } + + #[test] + fn test_add_local_bin_skips_fish_when_not_configured() { + with_temp_home(|home| { + add_local_bin_to_path().unwrap(); + assert!(!home.join(".config/fish").exists()); + }); + } + + #[test] + fn test_add_local_bin_writes_fish_snippet_when_fish_configured() { + with_temp_home(|home| { + fs::create_dir_all(home.join(".config/fish")).unwrap(); + add_local_bin_to_path().unwrap(); + let fish_file = home.join(".config/fish/conf.d/rigenv.fish"); + assert!(fish_file.exists()); + let content = fs::read_to_string(&fish_file).unwrap(); + assert!(content.contains("fish_add_path")); + assert!(content.contains("$HOME/.local/bin")); + }); + } + + #[test] + fn test_add_local_bin_refreshes_stale_fish_snippet() { + with_temp_home(|home| { + let fish_confd = home.join(".config/fish/conf.d"); + fs::create_dir_all(&fish_confd).unwrap(); + let fish_file = fish_confd.join("rigenv.fish"); + fs::write(&fish_file, "stale fish content").unwrap(); + add_local_bin_to_path().unwrap(); + let content = fs::read_to_string(&fish_file).unwrap(); + assert_ne!(content, "stale fish content"); + assert!(content.contains("fish_add_path")); + }); + } + + #[test] + fn test_check_local_bin_path_ok_when_binary_dir_on_path() { + with_temp_home(|home| { + let bin_dir = home.join("mybin"); + fs::create_dir_all(&bin_dir).unwrap(); + let bin_str = bin_dir.to_str().unwrap().to_string(); + unsafe { + std::env::set_var("RIG_BINARY_DIR", &bin_str); + std::env::set_var("PATH", &bin_str); + } + // Should succeed without error regardless of WARN_DONE state. + check_local_bin_path().unwrap(); + }); + } #[test] fn os_converts_to_osstring() { diff --git a/src/windows.rs b/src/windows.rs index 2db275d8..0cd08a05 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -39,15 +39,11 @@ use crate::windows_arch::*; // get_r_root(): returns the directory where the R versions are installed // RIG_LINKS_DIR: directory where the quick links are created // R_VERSION_DIR: name of the directory of a single R version inside R_ROOT() -// R_SYSLIBPATH: path to the system library of an R version from R_ROOT() -// R_BINPATH: path of the R executable from R_ROOT() +// get_r_syslibpath(): path to the system library of an R version from R_ROOT() +// get_r_binpath(): path of the R executable from R_ROOT() pub const RIG_LINKS_DIR: &str = "C:\\Program Files\\R\\bin"; pub const R_VERSIONDIR: &str = "R-{}"; -pub const R_SYSLIBPATH: &str = "R-{}\\library"; -pub const R_BASE_PROFILE: &str = "R-{}\\library\\base\\R\\Rprofile"; -pub const R_BINPATH: &str = "R-{}\\bin\\R.exe"; -pub const R_ETC_PATH: &str = "R-{}\\etc"; macro_rules! osvec { // match a list of expressions separated by comma: @@ -58,8 +54,8 @@ macro_rules! osvec { }); } -pub fn get_r_root() -> String { - get_r_root_arch(get_native_arch()) +pub fn get_r_root() -> Result> { + Ok(get_r_root_arch(get_native_arch())) } pub fn get_r_root_arch(arch: &str) -> String { @@ -97,8 +93,8 @@ pub fn arch_of_name(name: &str) -> &'static str { } // Return the R root directory for a given rig version name. -pub fn get_r_root_for(name: &str) -> String { - get_r_root_arch(arch_of_name(name)) +pub fn get_r_root_for(name: &str) -> Result> { + Ok(get_r_root_arch(arch_of_name(name))) } // Return the bare directory key (R-{key}) used inside the root, stripping arch suffix. @@ -117,6 +113,22 @@ fn rig_name_for_arch(base: &str, arch: &str) -> String { } } +pub fn get_r_syslibpath() -> Result> { + Ok("R-{}\\library".to_string()) +} + +pub fn get_r_binpath() -> Result> { + Ok("R-{}\\bin\\R.exe".to_string()) +} + +pub fn get_r_base_profile() -> Result> { + Ok("R-{}\\library\\base\\R\\Rprofile".to_string()) +} + +pub fn get_r_etc_path() -> Result> { + Ok("R-{}\\etc".to_string()) +} + #[warn(unused_variables)] pub fn sc_add(args: &ArgMatches) -> Result<(), Box> { escalate("adding new R version")?; @@ -279,7 +291,7 @@ fn patch_for_rtools() -> Result<(), Box> { continue; } let rtools4 = needed[0].version == "40"; - let ver_rroot = get_r_root_for(&ver); + let ver_rroot = get_r_root_for(&ver)?; let ver_base = version_dir_key(&ver); let rdir = "R-".to_string() + &ver_base; let envfile = Path::new(&ver_rroot).join(rdir).join("etc").join("Renviron.site"); @@ -338,7 +350,7 @@ fn get_rtools_needed( continue; } } - let ver_rroot = get_r_root_for(&ver); + let ver_rroot = get_r_root_for(&ver)?; let ver_base = version_dir_key(&ver); let r = Path::new(&ver_rroot) .join("R-".to_string() + &ver_base) @@ -412,7 +424,7 @@ pub fn sc_rm(args: &ArgMatches) -> Result<(), Box> { } } - let rroot = get_r_root_for(&ver); + let rroot = get_r_root_for(&ver)?; let base = version_dir_key(&ver); let dir = Path::new(&rroot).join("R-".to_string() + &base); OUTPUT.status(&format!("Removing {}", dir.display())); @@ -475,7 +487,7 @@ pub fn sc_system_make_links() -> Result<(), Box> { let filename = "R-".to_string() + &ver + ".bat"; let linkfile = linkdir.join(&filename); new_links.push(filename); - let ver_rroot = get_r_root_for(&ver); + let ver_rroot = get_r_root_for(&ver)?; let ver_base = version_dir_key(&ver); let target = Path::new(&ver_rroot).join("R-".to_string() + &ver_base); @@ -696,7 +708,7 @@ pub fn sc_get_list() -> Result, Box> { let mut vers = Vec::new(); // Always scan the native root (plain names). - list_r_in_root(&get_r_root(), "", &mut vers)?; + list_r_in_root(&get_r_root()?, "", &mut vers)?; // On aarch64, also scan the x86_64 root and emit names with -x86_64 suffix. if get_native_arch() == "aarch64" { @@ -711,7 +723,7 @@ pub fn sc_get_list() -> Result, Box> { pub fn sc_set_default(ver: &str) -> Result<(), Box> { let ver = check_installed(&ver.to_string())?; escalate("setting the default R version")?; - let rroot = get_r_root_for(&ver); + let rroot = get_r_root_for(&ver)?; let base = version_dir_key(&ver); let linkdir = Path::new(RIG_LINKS_DIR); std::fs::create_dir_all(&linkdir)?; @@ -904,7 +916,7 @@ fn maybe_update_registry_default() -> Result<(), Box> { fn update_registry_default1(key: &RegKey, ver: &String) -> Result<(), Box> { let base = version_dir_key(ver); - let rroot = get_r_root_for(ver); + let rroot = get_r_root_for(ver)?; key.set_value("Current Version", &base)?; let inst = rroot + "\\R-" + &base; key.set_value("InstallPath", &inst)?; @@ -1321,7 +1333,7 @@ pub fn sc_rstudio_( } pub fn get_system_profile(rver: &str) -> Result> { - let rroot = get_r_root_for(rver); + let rroot = get_r_root_for(rver)?; let base = version_dir_key(rver); let path = Path::new(&rroot).join("R-".to_string() + &base); let profile = path.join("library/base/R/Rprofile"); @@ -1330,7 +1342,7 @@ pub fn get_system_profile(rver: &str) -> Result> { pub fn get_r_binary(rver: &str) -> Result> { debug!("Finding R {} binary", rver); - let rroot = get_r_root_for(rver); + let rroot = get_r_root_for(rver)?; let base = version_dir_key(rver); let bin = Path::new(&rroot) .join("R-".to_string() + &base) @@ -1342,7 +1354,7 @@ pub fn get_r_binary(rver: &str) -> Result> { pub fn get_r_binary_x64(rver: &str) -> Result> { debug!("Finding R {} binary", rver); - let rroot = get_r_root_for(rver); + let rroot = get_r_root_for(rver)?; let base = version_dir_key(rver); let bin = Path::new(&rroot) .join("R-".to_string() + &base)