use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::paths;
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub save: SaveConfig,
pub display: DisplayConfig,
pub defaults: DefaultsConfig,
pub cache: CacheConfig,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct SaveConfig {
pub path: Option<PathBuf>,
pub format: String,
}
impl Default for SaveConfig {
fn default() -> Self {
Self {
path: None,
format: "auto".into(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct DisplayConfig {
pub emoji_glyphs: bool,
pub color: bool,
pub table_style: String,
}
impl Default for DisplayConfig {
fn default() -> Self {
Self {
emoji_glyphs: true,
color: true,
table_style: "rounded".into(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct DefaultsConfig {
pub galaxy: u8,
pub warp_range: Option<f64>,
pub tsp_algorithm: String,
pub find_limit: Option<usize>,
}
impl Default for DefaultsConfig {
fn default() -> Self {
Self {
galaxy: 0,
warp_range: None,
tsp_algorithm: "2opt".into(),
find_limit: None,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
pub enabled: bool,
pub path: Option<PathBuf>,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
path: None,
}
}
}
impl Config {
pub fn load() -> Result<Self, ConfigError> {
let path = paths::config_path();
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(path).map_err(ConfigError::Io)?;
toml::from_str(&content).map_err(ConfigError::Parse)
}
pub fn cache_path(&self) -> PathBuf {
self.cache.path.clone().unwrap_or_else(paths::cache_path)
}
pub fn save_path(&self) -> Option<&Path> {
self.save.path.as_deref()
}
pub fn cache_enabled(&self) -> bool {
self.cache.enabled
}
}
#[derive(Debug)]
pub enum ConfigError {
Io(std::io::Error),
Parse(toml::de::Error),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "config I/O error: {e}"),
Self::Parse(e) => write!(f, "config parse error: {e}"),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
Self::Parse(e) => Some(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.display.emoji_glyphs);
assert!(config.display.color);
assert_eq!(config.defaults.galaxy, 0);
assert!(config.cache.enabled);
assert!(config.save.path.is_none());
}
#[test]
fn test_parse_minimal_config() {
let toml = "";
let config: Config = toml::from_str(toml).unwrap();
assert!(config.display.emoji_glyphs);
}
#[test]
fn test_parse_full_config() {
let toml = r#"
[save]
path = "/Users/test/NMS"
format = "raw"
[display]
emoji_glyphs = false
color = false
table_style = "ascii"
[defaults]
galaxy = 1
warp_range = 2500.0
tsp_algorithm = "nearest-neighbor"
find_limit = 10
[cache]
enabled = false
path = "/tmp/nms-cache.rkyv"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(
config.save.path.as_deref().unwrap().to_str().unwrap(),
"/Users/test/NMS"
);
assert_eq!(config.save.format, "raw");
assert!(!config.display.emoji_glyphs);
assert!(!config.display.color);
assert_eq!(config.defaults.galaxy, 1);
assert_eq!(config.defaults.warp_range, Some(2500.0));
assert_eq!(config.defaults.find_limit, Some(10));
assert!(!config.cache.enabled);
}
#[test]
fn test_parse_partial_config() {
let toml = r#"
[defaults]
warp_range = 1500.0
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.display.emoji_glyphs);
assert!(config.cache.enabled);
assert_eq!(config.defaults.warp_range, Some(1500.0));
}
#[test]
fn test_load_nonexistent_returns_default() {
let config = Config::load_from(Path::new("/nonexistent/config.toml")).unwrap();
assert!(config.display.emoji_glyphs);
}
#[test]
fn test_load_invalid_toml_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.toml");
fs::write(&path, "not valid toml [[[").unwrap();
assert!(Config::load_from(&path).is_err());
}
#[test]
fn test_cache_path_default() {
let config = Config::default();
let path = config.cache_path();
assert!(path.ends_with("galaxy.rkyv"));
}
#[test]
fn test_cache_path_override() {
let toml = r#"
[cache]
path = "/tmp/custom-cache.rkyv"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.cache_path(), PathBuf::from("/tmp/custom-cache.rkyv"));
}
#[test]
fn test_save_path_none_when_unset() {
let config = Config::default();
assert!(config.save_path().is_none());
}
#[test]
fn test_unknown_fields_are_ignored() {
let toml = r#"
[save]
path = "/tmp"
unknown_field = "ignored"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.save.path.is_some());
}
}