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 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(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
.filter_map(|e| {
let path = e.path();
(path.extension()? == "toml").then(|| path.file_stem()?.to_str().map(str::to_owned))?
})
.collect();
names.sort();
names
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn listing_ignores_a_directory_named_like_a_setup() {
let dir = std::env::temp_dir()
.join(format!("rproj-list-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("trap.toml")).expect("create the trap");
fs::write(dir.join("real.toml"), "packages = []\npackage_workflow = \"wally\"\n")
.expect("write a real one");
fs::write(dir.join("notes.txt"), "not a setup").expect("write");
let mut names: Vec<String> = fs::read_dir(&dir)
.expect("read_dir")
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
.filter_map(|e| {
let path = e.path();
(path.extension()? == "toml").then(|| path.file_stem()?.to_str().map(str::to_owned))?
})
.collect();
names.sort();
assert_eq!(names, ["real"], "the directory and the .txt are both out");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn the_workflow_enum_is_written_in_kebab_case() {
let text = toml::to_string_pretty(&ProjectConfig {
mode: "guided".into(),
package_workflow: PackageWorkflow::GitSubmodules,
packages: vec![],
tools_at_creation: vec![],
})
.expect("serialise");
assert!(text.contains("package_workflow = \"git-submodules\""), "{text}");
assert!(!text.contains("GitSubmodules"), "{text}");
}
}