use serde::Deserialize;
use std::process::Command;
#[derive(Debug, Clone, Deserialize)]
pub struct RigVersion {
pub name: String,
pub default: bool,
pub version: String,
#[serde(default)]
pub aliases: Vec<String>,
#[allow(dead_code)]
pub path: String,
pub binary: String,
}
#[derive(Debug, Clone)]
pub struct ResolvedVersion {
pub r_home: String,
pub version: String,
}
pub fn rig_available() -> bool {
Command::new("rig")
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn list_versions() -> Result<Vec<RigVersion>, RigError> {
let output = Command::new("rig")
.args(["list", "--json"])
.output()
.map_err(|e| RigError::CommandFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RigError::CommandFailed(stderr.to_string()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let fixed_json = fix_windows_json_paths(&stdout);
serde_json::from_str(&fixed_json).map_err(|e| RigError::ParseError(e.to_string()))
}
fn fix_windows_json_paths(json: &str) -> String {
let mut result = String::with_capacity(json.len() * 2);
let mut chars = json.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(&next) = chars.peek() {
if next == '\\' {
result.push(ch);
result.push(chars.next().unwrap());
} else if next == '"' {
result.push(ch);
} else {
result.push('\\');
result.push('\\');
}
} else {
result.push(ch);
}
} else {
result.push(ch);
}
}
result
}
pub fn resolve_version(spec: &str) -> Result<ResolvedVersion, RigError> {
let versions = list_versions()?;
if versions.is_empty() {
return Err(RigError::NoVersionsInstalled);
}
let version = match spec.to_lowercase().as_str() {
"default" => {
versions
.into_iter()
.find(|v| v.default)
.ok_or(RigError::NoDefaultVersion)?
}
_ => {
if let Some(v) = versions
.iter()
.find(|v| v.aliases.iter().any(|a| a.eq_ignore_ascii_case(spec)))
{
v.clone()
}
else if let Some(v) = versions.iter().find(|v| v.name == spec) {
v.clone()
}
else if let Some(v) = versions.iter().find(|v| v.version == spec) {
v.clone()
}
else {
let mut matches: Vec<_> = versions
.iter()
.filter(|v| v.version.starts_with(spec))
.collect();
if !matches.is_empty() {
matches.sort_by(|a, b| {
let va = parse_version(&a.version);
let vb = parse_version(&b.version);
vb.cmp(&va) });
matches[0].clone()
} else {
return Err(RigError::VersionNotFound(spec.to_string()));
}
}
}
};
let r_home = get_r_home_from_binary(&version.binary)?;
Ok(ResolvedVersion {
r_home,
version: version.version,
})
}
fn get_r_home_from_binary(binary_path: &str) -> Result<String, RigError> {
let output = Command::new(binary_path)
.arg("RHOME")
.output()
.map_err(|e| {
RigError::CommandFailed(format!("Failed to run {} RHOME: {}", binary_path, e))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RigError::CommandFailed(format!(
"{} RHOME failed: {}",
binary_path, stderr
)));
}
let r_home = String::from_utf8_lossy(&output.stdout).trim().to_string();
if r_home.is_empty() {
return Err(RigError::CommandFailed(format!(
"{} RHOME returned empty result",
binary_path
)));
}
Ok(r_home)
}
fn parse_version(s: &str) -> Option<semver::Version> {
semver::Version::parse(s).ok()
}
#[derive(Debug, Clone)]
pub enum RigError {
CommandFailed(String),
ParseError(String),
NoVersionsInstalled,
NoDefaultVersion,
VersionNotFound(String),
}
impl std::fmt::Display for RigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RigError::CommandFailed(msg) => write!(f, "rig command failed: {}", msg),
RigError::ParseError(msg) => write!(f, "failed to parse rig output: {}", msg),
RigError::NoVersionsInstalled => write!(f, "no R versions installed via rig"),
RigError::NoDefaultVersion => write!(f, "no default R version set in rig"),
RigError::VersionNotFound(v) => write!(f, "R version '{}' not found", v),
}
}
}
impl std::error::Error for RigError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rig_available() {
let _ = rig_available();
}
#[test]
fn test_parse_rig_json() {
let json = r#"[
{
"name": "4.5.2",
"default": true,
"version": "4.5.2",
"aliases": ["release"],
"path": "/opt/R/4.5.2",
"binary": "/opt/R/4.5.2/bin/R"
},
{
"name": "4.4.0",
"default": false,
"version": "4.4.0",
"aliases": [],
"path": "/opt/R/4.4.0",
"binary": "/opt/R/4.4.0/bin/R"
}
]"#;
let versions: Vec<RigVersion> = serde_json::from_str(json).unwrap();
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].name, "4.5.2");
assert!(versions[0].default);
assert_eq!(versions[0].aliases, vec!["release"]);
assert_eq!(versions[1].name, "4.4.0");
assert!(!versions[1].default);
}
#[test]
fn test_parse_version() {
let v = parse_version("4.5.2").unwrap();
assert_eq!(v.major, 4);
assert_eq!(v.minor, 5);
assert_eq!(v.patch, 2);
let v1 = parse_version("4.5.2").unwrap();
let v2 = parse_version("4.4.3").unwrap();
assert!(v1 > v2);
let v3 = parse_version("4.10.0").unwrap();
let v4 = parse_version("4.9.0").unwrap();
assert!(v3 > v4);
}
#[test]
fn test_fix_windows_json_paths() {
let broken_json = r#"[
{
"name": "4.5.2",
"default": true,
"version": "4.5.2",
"aliases": ["release"],
"path": "C:\Program Files\R\R-4.5.2",
"binary": "C:\Program Files\R\R-4.5.2\bin\R.exe"
}
]"#;
let fixed = fix_windows_json_paths(broken_json);
let versions: Vec<RigVersion> = serde_json::from_str(&fixed).unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].name, "4.5.2");
assert_eq!(versions[0].path, r"C:\Program Files\R\R-4.5.2");
assert_eq!(versions[0].binary, r"C:\Program Files\R\R-4.5.2\bin\R.exe");
}
#[test]
fn test_fix_windows_json_paths_preserves_already_escaped() {
let valid_json = r#"{"path": "C:\\Program Files\\R"}"#;
let fixed = fix_windows_json_paths(valid_json);
assert!(fixed.contains(r#"C:\\Program Files\\R"#));
assert!(!fixed.contains(r#"C:\\\\Program"#));
}
#[test]
fn test_fix_windows_json_paths_preserves_escaped_quotes() {
let json_with_quote = r#"{"name": "test\"value"}"#;
let fixed = fix_windows_json_paths(json_with_quote);
assert!(fixed.contains(r#"test\"value"#));
}
}