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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user