From 105e428a43043716320410eeb1e9fdb16b6a6e33 Mon Sep 17 00:00:00 2001 From: dailz Date: Sun, 12 Apr 2026 10:50:39 +0800 Subject: [PATCH] feat(core): add ColorConfig struct with TOML support Replace empty AppConfig stub with ColorConfig struct that stores level-to-color mappings as strings. Supports loading/saving from XDG config directory via TOML, with graceful degradation on missing/invalid files. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/core/Cargo.toml | 1 + crates/core/src/config.rs | 204 +++++++++++++++++++++++++++++++++++++- crates/core/src/error.rs | 13 +++ 3 files changed, 217 insertions(+), 1 deletion(-) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 44cc868..0dbb891 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -17,3 +17,4 @@ directories.workspace = true [dev-dependencies] insta.workspace = true +tempfile.workspace = true diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 91554aa..af44827 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -1 +1,203 @@ -pub struct AppConfig {/* TODO */} +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}" + ); + } +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 1fb7bfc..249901a 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -93,6 +93,13 @@ pub enum CoreError { source: toml::de::Error, }, + // ─── TomlSerialize 变体:TOML 序列化错误(写配置文件失败)────────────── + #[error("TOML serialization error: {source}")] + TomlSerialize { + #[source] + source: toml::ser::Error, + }, + // ─── Encoding 变体:编码错误(非 UTF-8 文件)──────────────────────────── #[error("encoding error at line {line}: {bytes:?}")] // 注意 {bytes:?} 中的 :? 是 Debug 格式化(而不是 Display)。 @@ -198,6 +205,12 @@ impl From for CoreError { } } +impl From for CoreError { + fn from(err: toml::ser::Error) -> Self { + CoreError::TomlSerialize { source: err } + } +} + // ─── 从 notify::Error 转换为 CoreError ────────────────────────────────────── // 当文件监控(file watch)出错时,自动将 notify 库的错误包装为 CoreError::Watch。 // notify 是 Rust 生态中用于监控文件变化的库(如文件被修改、创建、删除等)。