236 lines
5.8 KiB
Rust
236 lines
5.8 KiB
Rust
use directories::ProjectDirs;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use thiserror::Error;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum ConfigError {
|
|
#[error("no supported configuration directory for this platform")]
|
|
MissingProjectDir,
|
|
#[error("io error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("invalid config file: {0}")]
|
|
Parse(#[from] toml::de::Error),
|
|
#[error("could not encode config: {0}")]
|
|
Encode(#[from] toml::ser::Error),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct AppConfig {
|
|
pub window: WindowConfig,
|
|
pub animation: AnimationConfig,
|
|
pub sprite: SpriteConfig,
|
|
pub api: ApiConfig,
|
|
pub logging: LoggingConfig,
|
|
pub controls: ControlsConfig,
|
|
pub frontend: FrontendConfig,
|
|
}
|
|
|
|
impl Default for AppConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
window: WindowConfig::default(),
|
|
animation: AnimationConfig::default(),
|
|
sprite: SpriteConfig::default(),
|
|
api: ApiConfig::default(),
|
|
logging: LoggingConfig::default(),
|
|
controls: ControlsConfig::default(),
|
|
frontend: FrontendConfig::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct WindowConfig {
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub monitor_id: Option<String>,
|
|
pub scale: f32,
|
|
pub always_on_top: bool,
|
|
pub click_through: bool,
|
|
pub visible: bool,
|
|
}
|
|
|
|
impl Default for WindowConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
x: 200.0,
|
|
y: 200.0,
|
|
monitor_id: None,
|
|
scale: 1.0,
|
|
always_on_top: true,
|
|
click_through: false,
|
|
visible: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct AnimationConfig {
|
|
pub fps: u16,
|
|
pub idle_timeout_ms: u64,
|
|
}
|
|
|
|
impl Default for AnimationConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
fps: 30,
|
|
idle_timeout_ms: 3_000,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct SpriteConfig {
|
|
pub selected_pack: String,
|
|
pub sprite_packs_dir: String,
|
|
}
|
|
|
|
impl Default for SpriteConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
selected_pack: "default".to_string(),
|
|
sprite_packs_dir: "sprite-packs".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ApiConfig {
|
|
pub port: u16,
|
|
pub auth_token: String,
|
|
}
|
|
|
|
impl Default for ApiConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
port: 32_145,
|
|
auth_token: Uuid::new_v4().to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct LoggingConfig {
|
|
pub level: String,
|
|
}
|
|
|
|
impl Default for LoggingConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
level: "info".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ControlsConfig {
|
|
pub hotkey_enabled: bool,
|
|
pub recovery_hotkey: String,
|
|
}
|
|
|
|
impl Default for ControlsConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
hotkey_enabled: true,
|
|
recovery_hotkey: "Ctrl+Alt+P".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FrontendBackend {
|
|
Bevy,
|
|
Tauri,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct FrontendConfig {
|
|
pub backend: FrontendBackend,
|
|
pub debug_overlay_visible: bool,
|
|
}
|
|
|
|
impl Default for FrontendConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
backend: FrontendBackend::Bevy,
|
|
debug_overlay_visible: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn config_path(app_name: &str) -> Result<PathBuf, ConfigError> {
|
|
let dirs =
|
|
ProjectDirs::from("", "", app_name).ok_or(ConfigError::MissingProjectDir)?;
|
|
Ok(dirs.config_dir().join("config.toml"))
|
|
}
|
|
|
|
pub fn load_or_create(app_name: &str) -> Result<(PathBuf, AppConfig), ConfigError> {
|
|
let path = config_path(app_name)?;
|
|
load_or_create_at(&path)
|
|
}
|
|
|
|
pub fn load_or_create_at(path: &Path) -> Result<(PathBuf, AppConfig), ConfigError> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
if !path.exists() {
|
|
let cfg = AppConfig::default();
|
|
save(path, &cfg)?;
|
|
return Ok((path.to_path_buf(), cfg));
|
|
}
|
|
|
|
let raw = fs::read_to_string(path)?;
|
|
let cfg = toml::from_str::<AppConfig>(&raw)?;
|
|
Ok((path.to_path_buf(), cfg))
|
|
}
|
|
|
|
pub fn save(path: &Path, config: &AppConfig) -> Result<(), ConfigError> {
|
|
let encoded = toml::to_string_pretty(config)?;
|
|
fs::write(path, encoded)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{load_or_create_at, save, AppConfig};
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn bootstrap_writes_default_config() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let path = temp.path().join("config.toml");
|
|
let (_, config) = load_or_create_at(&path).expect("load or create");
|
|
assert_eq!(config.api.port, 32_145);
|
|
}
|
|
|
|
#[test]
|
|
fn save_roundtrip() {
|
|
let temp = TempDir::new().expect("tempdir");
|
|
let path = temp.path().join("config.toml");
|
|
let mut config = AppConfig::default();
|
|
config.window.x = 42.0;
|
|
config.frontend.backend = super::FrontendBackend::Tauri;
|
|
config.frontend.debug_overlay_visible = true;
|
|
|
|
save(&path, &config).expect("save");
|
|
let (_, loaded) = load_or_create_at(&path).expect("reload");
|
|
assert!((loaded.window.x - 42.0).abs() < f32::EPSILON);
|
|
assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri);
|
|
assert!(loaded.frontend.debug_overlay_visible);
|
|
}
|
|
}
|