use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub vm_library_path: PathBuf,
pub metadata_path: PathBuf,
pub ascii_art_path: PathBuf,
pub snapshot_prefix: String,
#[serde(default)]
pub default_iso_path: Option<PathBuf>,
pub default_memory_mb: u32,
pub default_cpu_cores: u32,
pub default_disk_size_gb: u32,
pub default_display: String,
pub default_enable_kvm: bool,
pub confirm_before_launch: bool,
pub enable_multi_gpu_passthrough: bool,
pub default_ivshmem_size_mb: u32,
pub show_gpu_warnings: bool,
pub single_gpu_enabled: bool,
pub single_gpu_auto_tty: bool,
pub single_gpu_dm_override: Option<String>,
pub looking_glass_client_path: Option<PathBuf>,
pub looking_glass_auto_launch: bool,
}
impl Default for Config {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let config_dir = dirs::config_dir()
.unwrap_or_else(|| home.join(".config"))
.join("vm-curator");
Self {
vm_library_path: home.join("vm-space"),
metadata_path: config_dir.join("metadata"),
ascii_art_path: config_dir.join("ascii"),
snapshot_prefix: "snapshot".to_string(),
default_iso_path: None,
default_memory_mb: 4096,
default_cpu_cores: 2,
default_disk_size_gb: 64,
default_display: "gtk".to_string(),
default_enable_kvm: true,
confirm_before_launch: true,
enable_multi_gpu_passthrough: false,
default_ivshmem_size_mb: 64,
show_gpu_warnings: true,
single_gpu_enabled: false,
single_gpu_auto_tty: false,
single_gpu_dm_override: None,
looking_glass_client_path: None,
looking_glass_auto_launch: true,
}
}
}
impl Config {
pub fn load() -> Result<Self> {
Self::load_from(&Self::config_file_path())
}
pub fn load_from(config_path: &Path) -> Result<Self> {
if config_path.exists() {
let content = std::fs::read_to_string(config_path)
.with_context(|| format!("Failed to read config from {:?}", config_path))?;
toml::from_str(&content)
.with_context(|| format!("Failed to parse config from {:?}", config_path))
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
self.save_to(&Self::config_file_path())
}
pub fn save_to(&self, config_path: &Path) -> Result<()> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory {:?}", parent))?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
std::fs::write(config_path, content)
.with_context(|| format!("Failed to write config to {:?}", config_path))?;
Ok(())
}
pub fn config_file_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from(".config"))
.join("vm-curator")
.join("config.toml")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_from_missing_path_returns_default() {
let cfg = Config::load_from(Path::new("/nonexistent/vm-curator/config.toml")).unwrap();
let default = Config::default();
assert_eq!(cfg.snapshot_prefix, default.snapshot_prefix);
assert_eq!(cfg.default_memory_mb, default.default_memory_mb);
assert_eq!(cfg.vm_library_path, default.vm_library_path);
}
#[test]
fn save_then_load_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("config.toml");
let cfg = Config {
snapshot_prefix: "custom-prefix".to_string(),
default_memory_mb: 8192,
default_iso_path: Some(PathBuf::from("/tmp/isos")),
single_gpu_enabled: true,
..Config::default()
};
cfg.save_to(&path).unwrap();
assert!(path.exists());
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded.snapshot_prefix, "custom-prefix");
assert_eq!(loaded.default_memory_mb, 8192);
assert_eq!(loaded.default_iso_path, Some(PathBuf::from("/tmp/isos")));
assert!(loaded.single_gpu_enabled);
}
#[test]
fn load_from_partial_toml_fills_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "snapshot_prefix = \"partial\"\n").unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded.snapshot_prefix, "partial");
assert_eq!(
loaded.default_cpu_cores,
Config::default().default_cpu_cores
);
}
#[test]
fn load_from_malformed_toml_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "this is = not valid = toml = [[[").unwrap();
assert!(Config::load_from(&path).is_err());
}
#[test]
fn config_file_path_ends_with_expected_segments() {
let path = Config::config_file_path();
assert!(path.ends_with("vm-curator/config.toml"));
}
}