use crate::util::fs::atomic_write;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config: {0}")]
ReadError(#[from] std::io::Error),
#[error("Failed to write config: {0}")]
WriteError(std::io::Error),
#[error("Failed to parse config: {0}")]
ParseError(#[from] toml::de::Error),
#[error("Failed to serialize config: {0}")]
SerializeError(#[from] toml::ser::Error),
#[error("Config directory not found")]
NoDirFound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ThemeName {
#[default]
Dark,
Light,
Solarized,
}
impl ThemeName {
pub fn label(&self) -> &'static str {
match self {
ThemeName::Dark => "Dark",
ThemeName::Light => "Light",
ThemeName::Solarized => "Solarized",
}
}
pub fn cycle(&self) -> Self {
match self {
ThemeName::Dark => ThemeName::Light,
ThemeName::Light => ThemeName::Solarized,
ThemeName::Solarized => ThemeName::Dark,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TuiSettings {
pub theme: ThemeName,
pub mouse: bool,
pub animations: bool,
pub show_timestamps: bool,
pub max_history: usize,
}
impl Default for TuiSettings {
fn default() -> Self {
Self {
theme: ThemeName::Dark,
mouse: true,
animations: true,
show_timestamps: false,
max_history: 100,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ChatSettings {
pub default_provider: Option<String>,
pub default_model: Option<String>,
pub show_thinking: bool,
pub deep_thinking: bool,
pub auto_scroll: bool,
}
impl Default for ChatSettings {
fn default() -> Self {
Self {
default_provider: None,
default_model: None,
show_thinking: true,
deep_thinking: false,
auto_scroll: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StudioSettings {
pub auto_save: bool,
pub auto_save_interval: u64,
pub tab_width: u8,
pub line_numbers: bool,
pub highlight_line: bool,
}
impl Default for StudioSettings {
fn default() -> Self {
Self {
auto_save: true,
auto_save_interval: 30,
tab_width: 2,
line_numbers: true,
highlight_line: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PathSettings {
pub workflows: PathBuf,
pub traces: PathBuf,
}
impl Default for PathSettings {
fn default() -> Self {
Self {
workflows: PathBuf::from("."),
traces: PathBuf::from(".nika/traces"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct TuiConfig {
pub tui: TuiSettings,
pub chat: ChatSettings,
pub studio: StudioSettings,
pub paths: PathSettings,
}
impl TuiConfig {
pub fn load() -> Result<Self, ConfigError> {
let path = Self::config_path()?;
if path.exists() {
let content = std::fs::read_to_string(&path)?;
let config: TuiConfig = toml::from_str(&content)?;
Ok(config)
} else {
Ok(Self::default())
}
}
pub fn load_or_default() -> Self {
Self::load().unwrap_or_default()
}
pub fn save(&self) -> Result<(), ConfigError> {
let path = Self::config_path()?;
let content = toml::to_string_pretty(self)?;
atomic_write(&path, content.as_bytes()).map_err(ConfigError::WriteError)?;
Ok(())
}
pub fn config_path() -> Result<PathBuf, ConfigError> {
let local = PathBuf::from(".nika/config.toml");
if local.parent().map(|p| p.exists()).unwrap_or(false) {
return Ok(local);
}
let nika_dir = PathBuf::from(".nika");
if !nika_dir.exists() {
std::fs::create_dir_all(&nika_dir).ok();
}
if nika_dir.exists() {
return Ok(nika_dir.join("config.toml"));
}
Err(ConfigError::NoDirFound)
}
pub fn exists() -> bool {
Self::config_path().map(|p| p.exists()).unwrap_or(false)
}
pub fn init_default() -> Result<bool, ConfigError> {
let path = Self::config_path()?;
if path.exists() {
return Ok(false); }
let config = Self::default();
config.save()?;
Ok(true) }
pub fn theme(&self) -> ThemeName {
self.tui.theme
}
pub fn set_theme(&mut self, theme: ThemeName) {
self.tui.theme = theme;
}
pub fn cycle_theme(&mut self) {
self.tui.theme = self.tui.theme.cycle();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_theme_name_cycle() {
assert_eq!(ThemeName::Dark.cycle(), ThemeName::Light);
assert_eq!(ThemeName::Light.cycle(), ThemeName::Solarized);
assert_eq!(ThemeName::Solarized.cycle(), ThemeName::Dark);
}
#[test]
fn test_theme_name_label() {
assert_eq!(ThemeName::Dark.label(), "Dark");
assert_eq!(ThemeName::Light.label(), "Light");
assert_eq!(ThemeName::Solarized.label(), "Solarized");
}
#[test]
fn test_config_defaults() {
let config = TuiConfig::default();
assert_eq!(config.tui.theme, ThemeName::Dark);
assert!(config.tui.mouse);
assert!(config.tui.animations);
assert_eq!(config.tui.max_history, 100);
assert!(config.chat.show_thinking);
assert!(!config.chat.deep_thinking);
assert!(config.chat.auto_scroll);
assert!(config.studio.auto_save);
assert_eq!(config.studio.auto_save_interval, 30);
assert_eq!(config.studio.tab_width, 2);
}
#[test]
fn test_config_serialize_deserialize() {
let config = TuiConfig::default();
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("[tui]"));
assert!(toml_str.contains("[chat]"));
assert!(toml_str.contains("[studio]"));
assert!(toml_str.contains("[paths]"));
let parsed: TuiConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.tui.theme, ThemeName::Dark);
}
#[test]
fn test_config_parse_partial() {
let partial = r#"
[tui]
theme = "light"
[chat]
show_thinking = false
"#;
let config: TuiConfig = toml::from_str(partial).unwrap();
assert_eq!(config.tui.theme, ThemeName::Light);
assert!(config.tui.mouse); assert!(!config.chat.show_thinking); assert!(config.studio.auto_save); }
#[test]
fn test_config_set_theme() {
let mut config = TuiConfig::default();
assert_eq!(config.theme(), ThemeName::Dark);
config.set_theme(ThemeName::Solarized);
assert_eq!(config.theme(), ThemeName::Solarized);
config.cycle_theme();
assert_eq!(config.theme(), ThemeName::Dark);
}
#[test]
fn test_tui_settings_default() {
let settings = TuiSettings::default();
assert_eq!(settings.theme, ThemeName::Dark);
assert!(settings.mouse);
assert!(settings.animations);
}
#[test]
fn test_chat_settings_default() {
let settings = ChatSettings::default();
assert!(settings.default_provider.is_none());
assert!(settings.default_model.is_none());
assert!(settings.show_thinking);
}
#[test]
fn test_studio_settings_default() {
let settings = StudioSettings::default();
assert!(settings.auto_save);
assert_eq!(settings.auto_save_interval, 30);
assert_eq!(settings.tab_width, 2);
assert!(settings.line_numbers);
}
#[test]
fn test_path_settings_default() {
let settings = PathSettings::default();
assert_eq!(settings.workflows, PathBuf::from("."));
assert_eq!(settings.traces, PathBuf::from(".nika/traces"));
}
}