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

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