use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct GlobalConfig {
#[serde(default)]
pub roblox_projects_root: Option<PathBuf>,
#[serde(default)]
pub selected_system_apps: Vec<String>,
#[serde(default)]
pub selected_rokit_tools: Vec<String>,
#[serde(default)]
pub selected_studio_plugins: Vec<String>,
#[serde(default)]
pub selected_vscode_extensions: Vec<String>,
#[serde(default)]
pub last_checked: Option<String>,
}
impl GlobalConfig {
pub(crate) fn dirs() -> Result<ProjectDirs> {
ProjectDirs::from("", "", "rproj").context("could not determine a config directory for this platform")
}
fn path() -> Result<PathBuf> {
Ok(Self::dirs()?.config_dir().join("config.toml"))
}
pub fn load() -> Result<Self> {
let path = Self::path()?;
if !path.exists() {
return Ok(Self::default());
}
let text = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
}
pub fn save(&self) -> Result<()> {
let path = Self::path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let text = toml::to_string_pretty(self)?;
fs::write(&path, text).with_context(|| format!("failed to write {}", path.display()))
}
pub fn projects_root(&self) -> Result<PathBuf> {
if let Some(root) = &self.roblox_projects_root {
return Ok(root.clone());
}
let documents = dirs_documents()?;
Ok(documents.join("RobloxProjects"))
}
pub fn blender_enabled(&self) -> bool {
self.selected_system_apps.iter().any(|k| k == "blender")
}
pub fn machine_configured(&self) -> bool {
self.last_checked.is_some()
}
pub fn machine_summary(&self) -> String {
let parts = [
(self.selected_system_apps.len(), "apps"),
(self.selected_rokit_tools.len(), "tools"),
(self.selected_studio_plugins.len(), "plugins"),
(self.selected_vscode_extensions.len(), "extensions"),
];
let listed: Vec<String> = parts
.iter()
.filter(|(n, _)| *n > 0)
.map(|(n, label)| format!("{n} {label}"))
.collect();
if listed.is_empty() {
"nothing selected".to_string()
} else {
listed.join(", ")
}
}
}
fn dirs_documents() -> Result<PathBuf> {
let home = std::env::var_os("USERPROFILE").context("USERPROFILE is not set")?;
Ok(PathBuf::from(home).join("Documents"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PackageWorkflow {
Wally,
GitSubmodules,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectConfig {
pub mode: String,
pub package_workflow: PackageWorkflow,
pub packages: Vec<String>,
pub tools_at_creation: Vec<String>,
}
impl ProjectConfig {
pub fn path_in(project_dir: &Path) -> PathBuf {
project_dir.join("rproj.toml")
}
pub fn load_from(project_dir: &Path) -> Result<Option<Self>> {
let path = Self::path_in(project_dir);
if !path.exists() {
return Ok(None);
}
let text = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
Ok(Some(
toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))?,
))
}
pub fn save_to(&self, project_dir: &Path) -> Result<()> {
let path = Self::path_in(project_dir);
let text = toml::to_string_pretty(self)?;
fs::write(&path, text).with_context(|| format!("failed to write {}", path.display()))
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SavedSetup {
pub packages: Vec<String>,
pub package_workflow: PackageWorkflow,
}
impl SavedSetup {
fn dir() -> Result<PathBuf> {
Ok(GlobalConfig::dirs()?.config_dir().join("setups"))
}
fn path_for(name: &str) -> Result<PathBuf> {
Ok(Self::dir()?.join(format!("{name}.toml")))
}
pub fn save(&self, name: &str) -> Result<PathBuf> {
let path = Self::path_for(name)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(&path, toml::to_string_pretty(self)?)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(path)
}
pub fn load(name: &str) -> Result<Option<Self>> {
let path = Self::path_for(name)?;
if !path.exists() {
return Ok(None);
}
let text = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
Ok(Some(
toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))?,
))
}
pub fn list() -> Vec<String> {
let Ok(dir) = Self::dir() else { return Vec::new() };
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let path = e.path();
(path.extension()? == "toml").then(|| path.file_stem()?.to_str().map(str::to_owned))?
})
.collect();
names.sort();
names
}
}