use std::fs;
use std::path::Path;
use anyhow::{bail, Result};
use crate::catalog::tool_catalog::{Detect, ToolEntry, ToolKind};
use crate::steps::{capture, probe, run};
use crate::ui;
pub fn is_installed(entry: &ToolEntry) -> bool {
match entry.kind {
ToolKind::SystemApp { winget_id, detect } => {
let by_winget = || probe("winget", &["list", "--id", winget_id, "-e"]);
match detect {
Detect::Winget => by_winget(),
Detect::ExeUnder { env_var, subdir, exe } => {
exe_exists_under(env_var, subdir, exe) || by_winget()
}
}
}
_ => false,
}
}
fn exe_exists_under(env_var: &str, subdir: &str, exe: &str) -> bool {
let Some(base) = std::env::var_os(env_var) else {
return false;
};
exe_exists_in(Path::new(&base), subdir, exe)
}
fn exe_exists_in(base: &Path, subdir: &str, exe: &str) -> bool {
let mut root = base.to_path_buf();
for part in subdir.split('/') {
root.push(part);
}
if root.join(exe).is_file() {
return true;
}
let Ok(entries) = fs::read_dir(&root) else {
return false;
};
entries
.filter_map(|e| e.ok())
.any(|e| e.path().join(exe).is_file())
}
pub fn install(entry: &ToolEntry) -> Result<()> {
match entry.kind {
ToolKind::SystemApp { winget_id, .. } => install_winget(winget_id),
_ => Ok(()),
}
}
fn install_winget(winget_id: &str) -> Result<()> {
let args = [
"install",
"--id",
winget_id,
"-e",
"--accept-source-agreements",
"--accept-package-agreements",
];
let output = capture("winget", &args, None)?;
if !output.success || ui::is_verbose() {
ui::passthrough(&output.stdout, &output.stderr);
}
if output.success {
return Ok(());
}
if output.combined().contains("Installer hash does not match") {
bail!("winget's pinned installer hash is stale (an upstream winget-pkgs issue, not rproj)");
}
bail!("winget install failed");
}
pub const WINGET_HASH_HELP: &str = "A vendor updated their installer behind a static URL faster than winget's manifest.\n\
- Try again in a few days; the winget-pkgs bots usually catch up\n\
- Or install it directly (for Studio: https://www.roblox.com/create)\n\
- Or, as admin, `winget settings --enable InstallerHashOverride` once, then retry";
pub fn ensure_rokit() -> Result<()> {
if probe("rokit", &["--version"]) {
ui::ok("rokit already installed");
return Ok(());
}
run("cargo", &["install", "rokit", "--locked"])?;
run("rokit", &["self-install"])
}
pub fn ensure_projects_folder(root: &Path) -> Result<()> {
if root.exists() {
ui::ok(&format!("{} already exists", root.display()));
return Ok(());
}
fs::create_dir_all(root)?;
ui::ok(&format!("created {}", root.display()));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::tool_catalog::SYSTEM_APPS;
#[test]
fn studio_is_not_detected_through_winget() {
let studio = SYSTEM_APPS.iter().find(|e| e.key == "studio").expect("a studio entry");
let ToolKind::SystemApp { detect, .. } = studio.kind else {
panic!("studio must be a system app");
};
assert!(
matches!(detect, Detect::ExeUnder { .. }),
"studio must not use winget detection: {detect:?}"
);
}
#[test]
fn every_other_system_app_uses_winget_detection() {
for entry in SYSTEM_APPS.iter().filter(|e| e.key != "studio") {
let ToolKind::SystemApp { detect, .. } = entry.kind else { continue };
assert_eq!(detect, Detect::Winget, "{} should detect via winget", entry.key);
}
}
fn scratch(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("rproj-detect-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn an_exe_one_level_below_the_root_is_found() {
let base = scratch("versioned");
let versioned = base.join("App").join("Versions").join("version-abc123");
fs::create_dir_all(&versioned).expect("create");
fs::write(versioned.join("Thing.exe"), b"").expect("write");
assert!(exe_exists_in(&base, "App/Versions", "Thing.exe"));
assert!(!exe_exists_in(&base, "App/Versions", "Missing.exe"));
assert!(!exe_exists_in(&base, "App/Nope", "Thing.exe"));
let _ = fs::remove_dir_all(&base);
}
#[test]
fn an_exe_directly_in_the_root_is_found() {
let base = scratch("flat");
fs::create_dir_all(base.join("App")).expect("create");
fs::write(base.join("App").join("Thing.exe"), b"").expect("write");
assert!(exe_exists_in(&base, "App", "Thing.exe"));
let _ = fs::remove_dir_all(&base);
}
#[test]
fn a_directory_named_like_the_exe_is_not_a_match() {
let base = scratch("dir-named-exe");
fs::create_dir_all(base.join("App").join("Thing.exe")).expect("create");
assert!(!exe_exists_in(&base, "App", "Thing.exe"));
let _ = fs::remove_dir_all(&base);
}
#[test]
fn an_unset_environment_variable_is_not_installed() {
assert!(!exe_exists_under("RPROJ_TEST_DETECT_UNSET_VAR", "App", "Thing.exe"));
}
}