#[cfg(not(windows))]
use std::path::PathBuf;
use std::process::Command;
#[cfg(not(windows))]
use std::process::Stdio;
#[cfg(not(windows))]
use std::thread;
#[cfg(not(windows))]
use std::time::{Duration, Instant};
#[cfg(not(windows))]
struct RLessEnvironment {
_temp: tempfile::TempDir,
bin_dir: PathBuf,
home_dir: PathBuf,
fake_r_home: PathBuf,
}
#[cfg(unix)]
struct FakeRigEnvironment {
_temp: tempfile::TempDir,
bin_dir: PathBuf,
fake_r_home: PathBuf,
rig: PathBuf,
r: PathBuf,
}
#[cfg(unix)]
impl FakeRigEnvironment {
fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let bin_dir = temp.path().join("bin");
std::fs::create_dir(&bin_dir).unwrap();
let fake_r_home = temp.path().join("fallback-r-home");
std::fs::create_dir(&fake_r_home).unwrap();
let r = bin_dir.join("R");
write_executable(
&r,
r#"#!/bin/sh
if [ "$1" = "RHOME" ]; then
printf '%s\n' "$FAKE_R_HOME"
exit 0
fi
exit 1
"#,
);
let rig = bin_dir.join("rig");
write_executable(
&rig,
r#"#!/bin/sh
if [ "$1" = "--version" ]; then exit 0; fi
if [ "$1" = "list" ]; then printf '%s\n' '[{"name":"4.4.2","default":true,"version":"4.4.2","aliases":["my-proj"],"path":"/fake/R/4.4.2","binary":"R"} ]'; exit 0; fi
exit 1
"#,
);
Self {
_temp: temp,
bin_dir,
fake_r_home,
rig,
r,
}
}
fn command(&self) -> Command {
let mut command = isolated_command(self._temp.path());
command
.env("PATH", &self.bin_dir)
.env("FAKE_R_HOME", &self.fake_r_home)
.env_remove("R_HOME")
.env_remove("ARF_R_HOME")
.env_remove("ARF_R_VERSION");
command
}
}
fn isolated_command(temp_path: &std::path::Path) -> Command {
let home_dir = temp_path.join("home");
std::fs::create_dir_all(&home_dir).unwrap();
let mut command = Command::new(env!("CARGO_BIN_EXE_arf"));
command
.env("HOME", home_dir)
.env_remove("XDG_CONFIG_HOME")
.env_remove("XDG_DATA_HOME")
.env_remove("XDG_CACHE_HOME");
command
}
#[cfg(unix)]
fn write_executable(path: &std::path::Path, contents: &str) {
std::fs::write(path, contents).unwrap();
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).unwrap();
}
#[cfg(not(windows))]
impl RLessEnvironment {
fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let bin_dir = temp.path().join("bin");
std::fs::create_dir(&bin_dir).unwrap();
let home_dir = temp.path().join("home");
std::fs::create_dir(&home_dir).unwrap();
let fake_r_home = {
let fake_r_home = temp.path().join("fake-r-home");
let fake_r_library = arf_libr::r_library_path(&fake_r_home);
std::fs::create_dir_all(fake_r_library.parent().unwrap()).unwrap();
std::fs::write(fake_r_library, []).unwrap();
fake_r_home
};
#[cfg(unix)]
{
let r_command = bin_dir.join("R");
std::fs::write(
&r_command,
r#"#!/bin/sh
exit 1
"#,
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(&r_command).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&r_command, permissions).unwrap();
}
Self {
_temp: temp,
bin_dir,
home_dir,
fake_r_home,
}
}
fn command(&self) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_arf"));
#[cfg(unix)]
let path_entries = vec![self.bin_dir.clone()];
#[cfg(not(unix))]
let path_entries = vec![self.bin_dir.clone()];
command
.env_remove("R_HOME")
.env_remove("ARF_R_HOME")
.env_remove("ARF_R_VERSION")
.env_remove("XDG_CONFIG_HOME")
.env_remove("XDG_DATA_HOME")
.env_remove("XDG_CACHE_HOME")
.env("HOME", &self.home_dir)
.env("PATH", std::env::join_paths(path_entries).unwrap());
command
}
fn startup_command(&self) -> Command {
let mut command = self.command();
command.env("R_HOME", &self.fake_r_home);
command
}
}
#[test]
fn resolve_found_emits_descriptor_with_target() {
let temp = tempfile::tempdir().unwrap();
let output = isolated_command(temp.path())
.args(["r", "resolve", "--r-home"])
.arg(temp.path())
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
assert!(output.stderr.is_empty());
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["schema_version"], 1);
assert_eq!(value["resolved"], true);
assert_descriptor_path(&value, &value["target"]["r_home"], temp.path());
assert!(value["target"]["r_binary"].is_null());
assert!(value["target"]["resolved_version"].is_null());
assert_eq!(value["selected_by"]["kind"], "r_home");
assert_selected_by_nullable_fields_are_present(&value);
assert!(value["selected_by"]["requested_version"].is_null());
assert_eq!(
value["selected_by"]["source"]["kind"],
"command_line_argument"
);
assert_eq!(value["selected_by"]["source"]["name"], "--r-home");
assert!(value["selected_by"]["source"]["path"].is_null());
assert!(value["selected_by"]["source"]["format"].is_null());
assert!(value["selected_by"]["source"]["key"].is_null());
assert_descriptor_path(
&value,
&value["selected_by"]["requested_r_home"],
temp.path(),
);
assert!(value["diagnostics"].as_array().unwrap().is_empty());
insta::assert_snapshot!(
"resolve_found_emits_descriptor_with_target",
normalize_descriptor(&value)
);
}
#[test]
fn resolve_relative_r_home_uses_one_absolute_path_representation() {
let temp = tempfile::tempdir().unwrap();
let r_home = temp.path().join("rh");
std::fs::create_dir(&r_home).unwrap();
let output = isolated_command(temp.path())
.args(["r", "resolve", "--r-home", "rh"])
.current_dir(temp.path())
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let target_r_home = value["target"]["r_home"].as_str().unwrap();
let selected_path = value["selected_by"]["requested_r_home"].as_str().unwrap();
assert_eq!(target_r_home, selected_path);
assert!(std::path::Path::new(target_r_home).is_absolute());
assert_descriptor_path(&value, &value["target"]["r_home"], "rh");
assert_descriptor_path(&value, &value["selected_by"]["requested_r_home"], "rh");
}
#[cfg(unix)]
fn write_uninstalled_project_override_fixture() -> tempfile::TempDir {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("arf.toml"),
r#"[experimental]
r_source_overrides = [
{ type = "toml-key", file = "rproject.toml", key = "project.r_version" },
]
[startup]
r_source = { path = "fallback-r-home" }
"#,
)
.unwrap();
std::fs::create_dir(temp.path().join("fallback-r-home")).unwrap();
std::fs::write(
temp.path().join("rproject.toml"),
r#"[project]
r_version = "3.99.99"
"#,
)
.unwrap();
temp
}
#[test]
#[cfg(unix)]
fn resolve_uninstalled_project_override_falls_back_to_startup_source() {
let temp = write_uninstalled_project_override_fixture();
let environment = FakeRigEnvironment::new();
let fallback_output = environment
.command()
.args([
"r",
"resolve",
"--config",
"arf.toml",
"--no-r-source-overrides",
])
.current_dir(temp.path())
.output()
.unwrap();
assert!(
fallback_output.status.success(),
"stderr: {:?}",
fallback_output.stderr
);
let fallback_value: serde_json::Value =
serde_json::from_slice(&fallback_output.stdout).unwrap();
assert_eq!(fallback_value["resolved"], true);
let output = environment
.command()
.args(["r", "resolve", "--config", "arf.toml"])
.current_dir(temp.path())
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["resolved"], true);
assert_eq!(
value["target"]["r_home"],
fallback_value["target"]["r_home"]
);
assert_eq!(value["selected_by"]["kind"], "default");
assert_selected_by_nullable_fields_are_present(&value);
assert!(value["selected_by"]["requested_r_home"].is_null());
assert!(value["selected_by"]["requested_version"].is_null());
assert_eq!(value["selected_by"]["source"]["kind"], "configuration_file");
assert!(value["selected_by"]["source"]["name"].is_null());
assert_descriptor_path(&value, &value["selected_by"]["source"]["path"], "arf.toml");
assert_eq!(value["selected_by"]["source"]["format"], "toml");
assert_eq!(value["selected_by"]["source"]["key"], "startup.r_source");
let diagnostic = value["diagnostics"]
.as_array()
.unwrap()
.iter()
.find(|diagnostic| diagnostic["code"] == "r_source_override.version_not_installed")
.expect("missing uninstalled-version override diagnostic");
assert_eq!(diagnostic["path"], "rproject.toml");
}
#[test]
#[cfg(unix)]
fn resolve_project_file_version_request_reports_toml_source() {
let temp = write_uninstalled_project_override_fixture();
std::fs::write(
temp.path().join("rproject.toml"),
r#"[project]
r_version = "4.4.2"
"#,
)
.unwrap();
let environment = FakeRigEnvironment::new();
let output = environment
.command()
.args(["r", "resolve", "--config", "arf.toml"])
.current_dir(temp.path())
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["selected_by"]["kind"], "version_request");
assert_selected_by_nullable_fields_are_present(&value);
assert!(value["selected_by"]["requested_r_home"].is_null());
assert_eq!(value["selected_by"]["requested_version"], "4.4.2");
assert_eq!(value["selected_by"]["source"]["kind"], "project_file");
assert!(value["selected_by"]["source"]["name"].is_null());
assert_descriptor_path(
&value,
&value["selected_by"]["source"]["path"],
"rproject.toml",
);
assert_eq!(value["selected_by"]["source"]["format"], "toml");
assert_eq!(value["selected_by"]["source"]["key"], "project.r_version");
insta::assert_snapshot!(
"resolve_project_file_version_request_reports_toml_source",
normalize_descriptor(&value)
);
}
#[test]
#[cfg(unix)]
fn resolve_project_file_version_request_reports_text_source() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("arf.toml"),
r#"[experimental]
r_source_overrides = [{ type = "version-file", file = ".r-version" }]
[startup]
r_source = { path = "fallback-r-home" }
"#,
)
.unwrap();
std::fs::create_dir(temp.path().join("fallback-r-home")).unwrap();
std::fs::write(temp.path().join(".r-version"), "4.4.2\n").unwrap();
let environment = FakeRigEnvironment::new();
let output = environment
.command()
.args(["r", "resolve", "--config", "arf.toml"])
.current_dir(temp.path())
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["selected_by"]["kind"], "version_request");
assert_selected_by_nullable_fields_are_present(&value);
assert!(value["selected_by"]["requested_r_home"].is_null());
assert_eq!(value["selected_by"]["source"]["kind"], "project_file");
assert!(value["selected_by"]["source"]["name"].is_null());
assert_descriptor_path(
&value,
&value["selected_by"]["source"]["path"],
".r-version",
);
assert_eq!(value["selected_by"]["source"]["format"], "text");
assert!(value["selected_by"]["source"]["key"].is_null());
insta::assert_snapshot!(
"resolve_project_file_version_request_reports_text_source",
normalize_descriptor(&value)
);
}
#[test]
#[cfg(unix)]
fn resolve_project_hint_and_explicit_version_have_different_outcomes() {
let temp = write_uninstalled_project_override_fixture();
let environment = FakeRigEnvironment::new();
let project_hint = environment
.command()
.args(["r", "resolve", "--config", "arf.toml"])
.current_dir(temp.path())
.output()
.unwrap();
assert!(
project_hint.status.success(),
"stderr: {:?}",
project_hint.stderr
);
let project_hint_value: serde_json::Value =
serde_json::from_slice(&project_hint.stdout).unwrap();
assert_eq!(project_hint_value["resolved"], true);
assert_eq!(project_hint_value["selected_by"]["kind"], "default");
assert_selected_by_nullable_fields_are_present(&project_hint_value);
assert!(project_hint_value["selected_by"]["requested_r_home"].is_null());
assert!(project_hint_value["selected_by"]["requested_version"].is_null());
assert_eq!(
project_hint_value["selected_by"]["source"]["kind"],
"configuration_file"
);
let explicit_request = environment
.command()
.args([
"r",
"resolve",
"--config",
"arf.toml",
"--with-r-version",
"3.99.99",
])
.current_dir(temp.path())
.output()
.unwrap();
assert_structured_resolve_error(&explicit_request, "INVALID_PARAMS");
assert_eq!(explicit_request.status.code(), Some(2));
}
#[test]
#[cfg(unix)]
fn resolve_environment_version_is_invalid_invocation() {
let environment = FakeRigEnvironment::new();
let output = environment
.command()
.args(["r", "resolve"])
.env("ARF_R_VERSION", "3.99.99")
.output()
.unwrap();
assert_structured_resolve_error(&output, "INVALID_PARAMS");
assert_eq!(output.status.code(), Some(2));
}
#[test]
#[cfg(unix)]
fn resolve_environment_version_reports_environment_source() {
let environment = FakeRigEnvironment::new();
let output = environment
.command()
.args(["r", "resolve"])
.env("ARF_R_VERSION", "4.4.2")
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["selected_by"]["kind"], "version_request");
assert_selected_by_nullable_fields_are_present(&value);
assert!(value["selected_by"]["requested_r_home"].is_null());
assert_eq!(value["selected_by"]["requested_version"], "4.4.2");
assert_eq!(
value["selected_by"]["source"]["kind"],
"environment_variable"
);
assert_eq!(value["selected_by"]["source"]["name"], "ARF_R_VERSION");
insta::assert_snapshot!(
"resolve_environment_version_reports_environment_source",
normalize_descriptor(&value)
);
}
#[test]
#[cfg(unix)]
fn resolve_without_config_reports_built_in_default_source() {
let temp = tempfile::tempdir().unwrap();
let environment = FakeRigEnvironment::new();
let missing_config = temp.path().join("missing-arf.toml");
let output = environment
.command()
.args(["r", "resolve", "--config"])
.arg(missing_config)
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["selected_by"]["kind"], "default");
assert_selected_by_nullable_fields_are_present(&value);
assert!(value["selected_by"]["requested_r_home"].is_null());
assert!(value["selected_by"]["requested_version"].is_null());
assert_eq!(value["selected_by"]["source"]["kind"], "built_in_default");
assert!(value["selected_by"]["source"]["name"].is_null());
assert!(value["selected_by"]["source"]["path"].is_null());
assert!(value["selected_by"]["source"]["format"].is_null());
assert!(value["selected_by"]["source"]["key"].is_null());
insta::assert_snapshot!(
"resolve_without_config_reports_built_in_default_source",
normalize_descriptor(&value)
);
}
#[test]
#[cfg(not(windows))]
fn resolve_not_found_is_successful_false_descriptor() {
let environment = RLessEnvironment::new();
let output = environment
.command()
.args([
"r",
"resolve",
"--no-r-auto-discovery",
"--no-r-source-overrides",
])
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["resolved"], false);
assert!(value["target"].is_null());
assert!(value.as_object().unwrap().contains_key("provider"));
assert!(value["provider"].is_null());
let diagnostic = value["diagnostics"]
.as_array()
.unwrap()
.iter()
.find(|diagnostic| diagnostic["code"] == "r_discovery.failed")
.expect("expected an r_discovery.failed diagnostic");
insta::assert_snapshot!(
diagnostic["message"].as_str().unwrap(),
@"R automatic discovery is disabled. Set R_HOME, pass --r-home, or remove --no-r-auto-discovery."
);
insta::assert_snapshot!(
"resolve_not_found_is_successful_false_descriptor",
normalize_descriptor(&value)
);
}
#[test]
#[cfg(not(windows))]
fn resolve_explicit_r_home_still_works_with_no_r_auto_discovery() {
let environment = RLessEnvironment::new();
let output = environment
.command()
.args(["r", "resolve", "--no-r-auto-discovery", "--r-home"])
.arg(&environment.fake_r_home)
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["resolved"], true);
assert_descriptor_path(&value, &value["target"]["r_home"], &environment.fake_r_home);
assert_eq!(value["provider"], "explicit_path");
assert_eq!(value["selected_by"]["kind"], "r_home");
}
#[test]
fn resolve_broken_config_falls_back_with_diagnostic() {
let temp = tempfile::tempdir().unwrap();
let config = temp.path().join("broken.toml");
std::fs::write(&config, "not valid = [toml").unwrap();
let r_home = temp.path().join("r-home");
std::fs::create_dir(&r_home).unwrap();
let output = isolated_command(temp.path())
.args(["r", "resolve", "--config"])
.arg(&config)
.args(["--r-home"])
.arg(&r_home)
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
assert!(output.stderr.is_empty());
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["resolved"], true);
assert_descriptor_path(&value, &value["target"]["r_home"], &r_home);
assert!(
value["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| diagnostic["code"] == "config.parse_failed")
);
insta::assert_snapshot!(
"resolve_broken_config_falls_back_with_diagnostic",
normalize_descriptor(&value)
);
}
#[test]
fn resolve_environment_source_reports_environment_origin() {
let temp = tempfile::tempdir().unwrap();
let output = isolated_command(temp.path())
.args(["r", "resolve"])
.env("ARF_R_HOME", temp.path())
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["selected_by"]["kind"], "r_home");
assert_selected_by_nullable_fields_are_present(&value);
assert!(value["selected_by"]["requested_version"].is_null());
assert_eq!(
value["selected_by"]["source"]["kind"],
"environment_variable"
);
assert_eq!(value["selected_by"]["source"]["name"], "ARF_R_HOME");
}
#[test]
#[cfg(unix)]
fn resolve_invalid_version_is_invalid_invocation() {
let environment = FakeRigEnvironment::new();
let output = environment
.command()
.args(["r", "resolve", "--with-r-version", "not-a-version"])
.output()
.unwrap();
assert_structured_resolve_error(&output, "INVALID_PARAMS");
assert_eq!(output.status.code(), Some(2));
}
#[test]
#[cfg(unix)]
fn resolve_valid_but_uninstalled_version_is_invalid_invocation() {
let environment = FakeRigEnvironment::new();
let output = environment
.command()
.args(["r", "resolve", "--with-r-version", "3.99.99"])
.output()
.unwrap();
assert_structured_resolve_error(&output, "INVALID_PARAMS");
assert_eq!(output.status.code(), Some(2));
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert!(
error["error"]["message"]
.as_str()
.unwrap()
.contains("3.99.99")
);
}
#[test]
#[cfg(not(windows))]
fn resolve_version_without_rig_is_invalid_invocation() {
let environment = RLessEnvironment::new();
let output = environment
.command()
.args(["r", "resolve", "--with-r-version", "4.5.2"])
.output()
.unwrap();
assert_structured_resolve_error(&output, "INVALID_PARAMS");
assert_eq!(output.status.code(), Some(2));
}
#[test]
#[cfg(unix)]
fn resolve_rig_alias_is_accepted_before_version_spec_validation() {
let environment = FakeRigEnvironment::new();
let output = environment
.command()
.args(["r", "resolve", "--with-r-version", "my-proj"])
.output()
.unwrap();
assert!(output.status.success(), "stderr: {:?}", output.stderr);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(value["target"]["resolved_version"], "4.4.2");
assert_selected_by_nullable_fields_are_present(&value);
assert!(value["selected_by"]["requested_r_home"].is_null());
assert_eq!(value["selected_by"]["requested_version"], "my-proj");
assert_eq!(value["selected_by"]["kind"], "version_request");
assert_eq!(
value["selected_by"]["source"]["kind"],
"command_line_argument"
);
assert_eq!(value["selected_by"]["source"]["name"], "--with-r-version");
insta::assert_snapshot!(
"resolve_rig_alias_is_accepted_before_version_spec_validation",
normalize_descriptor(&value)
);
}
#[test]
#[cfg(unix)]
fn resolve_malformed_rig_output_is_internal_error() {
let environment = FakeRigEnvironment::new();
std::fs::write(
&environment.rig,
r#"#!/bin/sh
if [ "$1" = "--version" ]; then exit 0; fi
if [ "$1" = "list" ]; then printf '%s\n' 'not json'; exit 0; fi
exit 1
"#,
)
.unwrap();
let output = environment
.command()
.args(["r", "resolve", "--with-r-version", "4.4.2"])
.output()
.unwrap();
assert_structured_resolve_error(&output, "INTERNAL_ERROR");
assert_eq!(output.status.code(), Some(4));
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert!(
error["error"]["message"]
.as_str()
.unwrap()
.contains("failed to parse rig output")
);
}
#[test]
#[cfg(unix)]
fn resolve_failing_rig_is_internal_error() {
let environment = FakeRigEnvironment::new();
write_executable(&environment.rig, "#!/bin/sh\nexit 1\n");
let output = environment
.command()
.args(["r", "resolve", "--with-r-version", "4.4.2"])
.output()
.unwrap();
assert_structured_resolve_error(&output, "INTERNAL_ERROR");
assert_eq!(output.status.code(), Some(4));
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert!(
error["error"]["message"]
.as_str()
.unwrap()
.contains("Failed to run rig while checking whether it is available")
);
}
#[test]
#[cfg(unix)]
fn resolve_r_binary_failure_is_internal_error() {
let environment = FakeRigEnvironment::new();
std::fs::write(&environment.r, "#!/bin/sh\nexit 1\n").unwrap();
let output = environment
.command()
.args(["r", "resolve", "--with-r-version", "4.4.2"])
.output()
.unwrap();
assert_structured_resolve_error(&output, "INTERNAL_ERROR");
assert_eq!(output.status.code(), Some(4));
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert!(
error["error"]["message"]
.as_str()
.unwrap()
.contains("RHOME failed")
);
}
#[test]
fn resolve_missing_r_home_returns_invalid_params_exit_code() {
let temp = tempfile::tempdir().unwrap();
let missing_r_home = temp.path().join("missing-r-home");
let output = isolated_command(temp.path())
.args(["r", "resolve", "--r-home"])
.arg(missing_r_home)
.output()
.unwrap();
assert_structured_resolve_error(&output, "INVALID_PARAMS");
assert_eq!(output.status.code(), Some(2));
}
fn assert_structured_resolve_error(output: &std::process::Output, code: &str) {
assert!(output.stdout.is_empty());
let value: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
let error = value["error"].as_object().unwrap();
assert_eq!(error["code"], code);
assert!(error["message"].is_string());
assert!(error["hint"].is_null());
assert!(error["data"].is_null());
}
fn assert_selected_by_nullable_fields_are_present(value: &serde_json::Value) {
let selected_by = value["selected_by"].as_object().unwrap();
for field in ["requested_r_home", "requested_version"] {
assert!(
selected_by.contains_key(field),
"missing selected_by.{field}"
);
}
let source = selected_by["source"].as_object().unwrap();
for field in ["name", "path", "format", "key"] {
assert!(
source.contains_key(field),
"missing selected_by.source.{field}"
);
}
}
fn normalize_descriptor(value: &serde_json::Value) -> String {
let mut normalized = value.clone();
let descriptor = normalized.as_object_mut().unwrap();
replace_non_null(descriptor.get_mut("cwd"), "<cwd>");
if let Some(resolver) = descriptor
.get_mut("resolver")
.and_then(|resolver| resolver.as_object_mut())
{
replace_non_null(resolver.get_mut("version"), "<arf-version>");
}
if let Some(target) = descriptor
.get_mut("target")
.and_then(|target| target.as_object_mut())
{
replace_non_null(target.get_mut("r_home"), "<r-home>");
replace_non_null(target.get_mut("r_binary"), "<r-binary>");
}
if let Some(selected_by) = descriptor
.get_mut("selected_by")
.and_then(|selected_by| selected_by.as_object_mut())
{
replace_non_null(
selected_by.get_mut("requested_r_home"),
"<requested-r-home>",
);
if let Some(source) = selected_by
.get_mut("source")
.and_then(|source| source.as_object_mut())
{
replace_non_null(source.get_mut("path"), "<source-path>");
}
}
if let Some(diagnostics) = descriptor
.get_mut("diagnostics")
.and_then(|diagnostics| diagnostics.as_array_mut())
{
for diagnostic in diagnostics {
if let Some(diagnostic) = diagnostic.as_object_mut() {
let path = diagnostic
.get("path")
.and_then(|path| path.as_str())
.map(str::to_owned);
let message = diagnostic
.get("message")
.and_then(|message| message.as_str())
.map(str::to_owned);
if let (Some(path), Some(message)) = (path.as_deref(), message) {
diagnostic.insert(
"message".to_owned(),
serde_json::Value::String(message.replace(path, "<path>")),
);
}
replace_non_null(diagnostic.get_mut("path"), "<diagnostic-path>");
}
}
}
serde_json::to_string_pretty(&normalized).unwrap()
}
#[test]
fn normalize_descriptor_handles_paths_from_any_platform() {
for path in [
r"/tmp/.tmpABC/broken.toml",
r"C:\Users\runner\AppData\Local\Temp\.tmpABC\broken.toml",
r"\\server\share\broken.toml",
r"C:\Program Files\arf config\broken.toml",
] {
let value = serde_json::json!({
"diagnostics": [{
"code": "config.parse_failed",
"severity": "warning",
"message": format!("Failed to load config from {path}: TOML parse error"),
"path": path,
}],
});
let normalized = normalize_descriptor(&value);
assert!(
normalized.contains(r"<path>: TOML parse error"),
"path {path} normalized to: {normalized}"
);
assert!(!normalized.contains("broken.toml"), "leaked {path}");
}
let absolute_path = r"C:\Users\runner\AppData\Local\Temp\.tmpABC\broken.toml";
let home_relative_path = r"~\AppData\Local\Temp\.tmpABC\broken.toml";
let value = serde_json::json!({
"diagnostics": [{
"code": "config.parse_failed",
"severity": "warning",
"message": format!("Failed to load config from {home_relative_path}: TOML parse error"),
"path": absolute_path,
}],
});
let normalized: serde_json::Value =
serde_json::from_str(&normalize_descriptor(&value)).unwrap();
assert_eq!(
normalized["diagnostics"][0]["message"],
serde_json::Value::String(format!(
"Failed to load config from {home_relative_path}: TOML parse error"
)),
"expected the mismatched spelling to survive, so this test keeps \
documenting why the two fields must agree"
);
}
fn replace_non_null(value: Option<&mut serde_json::Value>, placeholder: &str) {
if let Some(value) = value
&& !value.is_null()
{
*value = serde_json::Value::String(placeholder.to_owned());
}
}
fn assert_descriptor_path(
value: &serde_json::Value,
actual: &serde_json::Value,
expected_path: impl AsRef<std::path::Path>,
) {
let cwd = std::path::Path::new(value["cwd"].as_str().unwrap());
let expected_path = expected_path.as_ref();
let expected = if expected_path.is_absolute() {
expected_path.to_owned()
} else {
cwd.join(expected_path)
};
assert_eq!(
actual,
&serde_json::Value::String(expected.display().to_string())
);
}
#[test]
#[cfg(not(windows))]
fn startup_survives_without_r_on_path() {
let environment = RLessEnvironment::new();
let mut child = environment
.startup_command()
.args(["--no-banner", "--no-history"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let timeout = Duration::from_secs(10);
let start = Instant::now();
loop {
if child.try_wait().unwrap().is_some() {
break;
}
if start.elapsed() >= timeout {
let _ = child.kill();
let output = child.wait_with_output().unwrap();
panic!(
"arf did not exit within {timeout:?}; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
}
thread::sleep(Duration::from_millis(50));
}
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"arf should start and exit cleanly without a usable R library: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stderr).contains("R evaluation will not be available"));
}