use crate::error::{HindsightError, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub keybindings: KeyBindings,
#[serde(default)]
pub analytics: AnalyticsConfig,
#[serde(default)]
pub performance: PerformanceConfig,
#[serde(default)]
pub paths: PathsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeDirConfig {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl ClaudeDirConfig {
#[allow(dead_code)]
pub fn display_name(&self) -> &str {
self.name.as_deref().unwrap_or(&self.path)
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ClaudeDirEntry {
Simple(String),
Full(ClaudeDirConfig),
}
fn deserialize_claude_dirs<'de, D>(deserializer: D) -> std::result::Result<Vec<ClaudeDirConfig>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
let entries = Vec::<ClaudeDirEntry>::deserialize(deserializer)?;
Ok(entries
.into_iter()
.map(|e| match e {
ClaudeDirEntry::Simple(s) => ClaudeDirConfig { path: s, name: None },
ClaudeDirEntry::Full(c) => c,
})
.collect())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathsConfig {
#[serde(default = "default_claude_dirs", deserialize_with = "deserialize_claude_dirs")]
pub claude_dirs: Vec<ClaudeDirConfig>,
}
impl Default for PathsConfig {
fn default() -> Self {
PathsConfig {
claude_dirs: default_claude_dirs(),
}
}
}
fn default_claude_dirs() -> Vec<ClaudeDirConfig> {
vec![ClaudeDirConfig {
path: "~/.claude/projects".to_string(),
name: None,
}]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_true")]
pub show_analytics: bool,
#[serde(default = "default_view")]
pub default_view: String,
#[serde(default = "default_true")]
pub show_line_numbers: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyBindings {
#[serde(default = "default_quit")]
pub quit: String,
#[serde(default = "default_refresh")]
pub refresh: String,
#[serde(default = "default_search")]
pub search: String,
#[serde(default = "default_settings")]
pub settings: String,
#[serde(default = "default_back")]
pub back: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyticsConfig {
#[serde(default = "default_true")]
pub show_top_tools: bool,
#[serde(default = "default_true")]
pub show_subagent_count: bool,
#[serde(default = "default_tools_limit")]
pub tools_limit: usize,
#[serde(default = "default_true")]
pub show_activity: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceConfig {
#[serde(default = "default_true")]
pub cache_sessions: bool,
#[serde(default = "default_cache_size")]
pub max_cache_size_mb: usize,
#[serde(default = "default_max_sessions")]
pub max_sessions_for_tools: usize,
}
fn default_theme() -> String {
"default".to_string()
}
fn default_view() -> String {
"projects".to_string()
}
fn default_quit() -> String {
"q".to_string()
}
fn default_refresh() -> String {
"r".to_string()
}
fn default_search() -> String {
"/".to_string()
}
fn default_settings() -> String {
"s".to_string()
}
fn default_back() -> String {
"h".to_string()
}
fn default_true() -> bool {
true
}
fn default_tools_limit() -> usize {
5
}
fn default_cache_size() -> usize {
100
}
fn default_max_sessions() -> usize {
100
}
impl Default for UiConfig {
fn default() -> Self {
UiConfig {
theme: default_theme(),
show_analytics: default_true(),
default_view: default_view(),
show_line_numbers: default_true(),
}
}
}
impl Default for KeyBindings {
fn default() -> Self {
KeyBindings {
quit: default_quit(),
refresh: default_refresh(),
search: default_search(),
settings: default_settings(),
back: default_back(),
}
}
}
impl Default for AnalyticsConfig {
fn default() -> Self {
AnalyticsConfig {
show_top_tools: default_true(),
show_subagent_count: default_true(),
tools_limit: default_tools_limit(),
show_activity: default_true(),
}
}
}
impl Default for PerformanceConfig {
fn default() -> Self {
PerformanceConfig {
cache_sessions: default_true(),
max_cache_size_mb: default_cache_size(),
max_sessions_for_tools: default_max_sessions(),
}
}
}
impl Config {
pub fn config_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir().ok_or_else(|| {
HindsightError::Config("Could not determine config directory".to_string())
})?;
let hindsight_dir = config_dir.join("claude-hindsight");
Ok(hindsight_dir.join("config.toml"))
}
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
if config_path.exists() {
let contents = fs::read_to_string(&config_path)?;
let config: Config = toml::from_str(&contents)
.map_err(|e| HindsightError::Config(format!("Failed to parse config: {}", e)))?;
Ok(config)
} else {
let config = Config::default();
config.save()?;
Ok(config)
}
}
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
let toml_string = toml::to_string_pretty(self)
.map_err(|e| HindsightError::Config(format!("Failed to serialize config: {}", e)))?;
fs::write(&config_path, toml_string)?;
Ok(())
}
pub fn reset() -> Result<Self> {
let config = Config::default();
config.save()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
let valid_themes = ["default", "dracula", "nord", "gruvbox", "monokai"];
if !valid_themes.contains(&self.ui.theme.as_str()) {
return Err(HindsightError::Config(format!(
"Invalid theme '{}'. Valid themes: {}",
self.ui.theme,
valid_themes.join(", ")
)));
}
let valid_views = ["projects", "last_session"];
if !valid_views.contains(&self.ui.default_view.as_str()) {
return Err(HindsightError::Config(format!(
"Invalid default_view '{}'. Valid views: {}",
self.ui.default_view,
valid_views.join(", ")
)));
}
if self.analytics.tools_limit == 0 || self.analytics.tools_limit > 20 {
return Err(HindsightError::Config(
"tools_limit must be between 1 and 20".to_string(),
));
}
if self.performance.max_cache_size_mb > 1000 {
return Err(HindsightError::Config(
"max_cache_size_mb cannot exceed 1000 MB".to_string(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.ui.theme, "default");
assert_eq!(config.keybindings.quit, "q");
assert_eq!(config.analytics.tools_limit, 5);
}
#[test]
fn test_config_validation() {
let mut config = Config::default();
assert!(config.validate().is_ok());
config.ui.theme = "invalid".to_string();
assert!(config.validate().is_err());
config = Config::default();
config.analytics.tools_limit = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_toml_serialization() {
let config = Config::default();
let toml_string = toml::to_string_pretty(&config).unwrap();
assert!(toml_string.contains("[ui]"));
assert!(toml_string.contains("[keybindings]"));
assert!(toml_string.contains("[analytics]"));
assert!(toml_string.contains("[performance]"));
}
}