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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-04-12 10:50:39 +08:00
parent 67a118a8c8
commit 105e428a43
3 changed files with 217 additions and 1 deletions

View File

@@ -17,3 +17,4 @@ directories.workspace = true
[dev-dependencies] [dev-dependencies]
insta.workspace = true insta.workspace = true
tempfile.workspace = true

View File

@@ -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<PathBuf> {
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}"
);
}
}

View File

@@ -93,6 +93,13 @@ pub enum CoreError {
source: toml::de::Error, source: toml::de::Error,
}, },
// ─── TomlSerialize 变体TOML 序列化错误(写配置文件失败)──────────────
#[error("TOML serialization error: {source}")]
TomlSerialize {
#[source]
source: toml::ser::Error,
},
// ─── Encoding 变体:编码错误(非 UTF-8 文件)──────────────────────────── // ─── Encoding 变体:编码错误(非 UTF-8 文件)────────────────────────────
#[error("encoding error at line {line}: {bytes:?}")] #[error("encoding error at line {line}: {bytes:?}")]
// 注意 {bytes:?} 中的 :? 是 Debug 格式化(而不是 Display // 注意 {bytes:?} 中的 :? 是 Debug 格式化(而不是 Display
@@ -198,6 +205,12 @@ impl From<toml::de::Error> for CoreError {
} }
} }
impl From<toml::ser::Error> for CoreError {
fn from(err: toml::ser::Error) -> Self {
CoreError::TomlSerialize { source: err }
}
}
// ─── 从 notify::Error 转换为 CoreError ────────────────────────────────────── // ─── 从 notify::Error 转换为 CoreError ──────────────────────────────────────
// 当文件监控file watch出错时自动将 notify 库的错误包装为 CoreError::Watch。 // 当文件监控file watch出错时自动将 notify 库的错误包装为 CoreError::Watch。
// notify 是 Rust 生态中用于监控文件变化的库(如文件被修改、创建、删除等)。 // notify 是 Rust 生态中用于监控文件变化的库(如文件被修改、创建、删除等)。