use crate::error::{CoreError, Result}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ColorConfig { #[serde(default = "ColorConfig::default_error")] pub error: String, #[serde(default = "ColorConfig::default_warn")] pub warn: String, #[serde(default = "ColorConfig::default_info")] pub info: String, #[serde(default = "ColorConfig::default_debug")] pub debug: String, #[serde(default = "ColorConfig::default_trace")] pub trace: String, #[serde(default = "ColorConfig::default_unknown")] pub unknown: String, } impl Default for ColorConfig { fn default() -> Self { Self { error: Self::default_error(), warn: Self::default_warn(), info: Self::default_info(), debug: Self::default_debug(), trace: Self::default_trace(), unknown: Self::default_unknown(), } } } impl ColorConfig { fn default_error() -> String { "red".into() } fn default_warn() -> String { "yellow".into() } fn default_info() -> String { "green".into() } fn default_debug() -> String { "blue".into() } fn default_trace() -> String { "cyan".into() } fn default_unknown() -> String { "gray".into() } pub fn config_path() -> Option { directories::ProjectDirs::from("", "", "log-viewer") .map(|dirs| dirs.config_dir().join("config.toml")) } pub fn load() -> Self { match Self::config_path() { Some(path) => Self::load_from(&path), None => Self::default(), } } pub fn load_from(path: &std::path::Path) -> Self { match std::fs::read_to_string(path) { Ok(contents) => toml::from_str(&contents).unwrap_or_default(), Err(_) => Self::default(), } } pub fn save(&self) -> Result<()> { match Self::config_path() { Some(path) => self.save_to(&path), None => Ok(()), } } pub fn save_to(&self, path: &std::path::Path) -> Result<()> { let toml_str = toml::to_string_pretty(self).map_err(|source| CoreError::TomlSerialize { source })?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| CoreError::Io { source: e, context: format!("creating config directory {}", parent.display()), })?; } std::fs::write(path, &toml_str).map_err(|e| CoreError::Io { source: e, context: format!("writing config to {}", path.display()), }) } } #[cfg(test)] mod tests { use super::*; use tempfile::NamedTempFile; #[test] fn test_default_values() { let config = ColorConfig::default(); assert_eq!(config.error, "red"); assert_eq!(config.warn, "yellow"); assert_eq!(config.info, "green"); assert_eq!(config.debug, "blue"); assert_eq!(config.trace, "cyan"); assert_eq!(config.unknown, "gray"); } #[test] fn test_save_and_load_roundtrip() { let file = NamedTempFile::new().unwrap(); let config = ColorConfig::default(); config.save_to(file.path()).unwrap(); let loaded = ColorConfig::load_from(file.path()); assert_eq!(config, loaded); // Verify the file contains valid TOML let contents = std::fs::read_to_string(file.path()).unwrap(); assert!(contents.contains("error = \"red\"")); drop(file); } #[test] fn test_load_from_missing_file_returns_default() { let loaded = ColorConfig::load_from(std::path::Path::new("/nonexistent/config.toml")); assert_eq!(loaded, ColorConfig::default()); } #[test] fn test_load_from_invalid_toml_returns_default() { let mut file = NamedTempFile::new().unwrap(); std::io::Write::write_all(&mut file, b"not valid toml {{{{").unwrap(); let loaded = ColorConfig::load_from(file.path()); assert_eq!(loaded, ColorConfig::default()); drop(file); } #[test] fn test_partial_toml_uses_defaults() { let mut file = NamedTempFile::new().unwrap(); std::io::Write::write_all(&mut file, b"error = \"magenta\"\nwarn = \"cyan\"\n").unwrap(); let loaded = ColorConfig::load_from(file.path()); assert_eq!(loaded.error, "magenta"); assert_eq!(loaded.warn, "cyan"); assert_eq!(loaded.info, "green"); assert_eq!(loaded.debug, "blue"); assert_eq!(loaded.trace, "cyan"); assert_eq!(loaded.unknown, "gray"); drop(file); } #[test] fn test_save_creates_parent_directories() { let dir = tempfile::tempdir().unwrap(); let nested_path = dir.path().join("a").join("b").join("config.toml"); let config = ColorConfig::default(); config.save_to(&nested_path).unwrap(); assert!(nested_path.exists()); let loaded = ColorConfig::load_from(&nested_path); assert_eq!(config, loaded); } #[test] fn test_toml_serialization_format() { let config = ColorConfig::default(); let toml_str = toml::to_string_pretty(&config).unwrap(); assert!(toml_str.contains("error = \"red\"")); assert!(toml_str.contains("warn = \"yellow\"")); assert!(toml_str.contains("info = \"green\"")); assert!(toml_str.contains("debug = \"blue\"")); assert!(toml_str.contains("trace = \"cyan\"")); assert!(toml_str.contains("unknown = \"gray\"")); } #[test] #[cfg(target_os = "linux")] fn test_config_path_is_under_xdg() { let path = ColorConfig::config_path().unwrap(); let path_str = path.to_string_lossy(); assert!( path_str.contains("log-viewer"), "path should contain 'log-viewer': {path_str}" ); assert!( path_str.ends_with("config.toml"), "path should end with config.toml: {path_str}" ); // Verify it uses the simple qualifier, not duplicate paths assert!( !path_str.contains("com/"), "path should not contain 'com/': {path_str}" ); } }