use std::collections::HashMap;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("YAML parse error: {0}")]
Parse(#[from] serde_yaml::Error),
#[error("Validation error: {0}")]
Validation(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum KeybindingPreset {
Vim,
Emacs,
#[default]
Default,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ProviderType {
Anthropic,
Openai,
Openrouter,
Ollama,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ThemeColors {
pub bg: Option<String>,
pub fg: Option<String>,
pub sidebar_bg: Option<String>,
pub sidebar_fg: Option<String>,
pub sidebar_selected: Option<String>,
pub user_msg_bg: Option<String>,
pub assistant_msg_bg: Option<String>,
pub thinking_fg: Option<String>,
pub accent: Option<String>,
pub error: Option<String>,
pub success: Option<String>,
pub warning: Option<String>,
pub border: Option<String>,
pub code_bg: Option<String>,
pub code_fg: Option<String>,
pub heading: Option<String>,
pub link: Option<String>,
pub tag: Option<String>,
pub sidebar_selected_bg: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProviderConfig {
pub name: String,
pub provider_type: ProviderType,
pub api_key: Option<String>,
pub base_url: Option<String>,
pub default_model: Option<String>,
#[serde(default)]
pub models: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct DefaultsConfig {
pub provider: Option<String>,
pub model: Option<String>,
pub system_prompt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolConfig {
pub name: String,
pub description: String,
pub command: String,
pub input_schema: Option<serde_json::Value>,
#[serde(default = "default_timeout")]
pub timeout: String,
#[serde(default)]
pub confirm: bool,
#[serde(default)]
pub env: HashMap<String, String>,
}
fn default_timeout() -> String {
"30s".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DatabaseConfig {
#[serde(default = "default_database_path")]
pub path: String,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
path: default_database_path(),
}
}
}
fn default_database_path() -> String {
ProjectDirs::from("com", "tuillem", "tuillem")
.map(|dirs| {
dirs.data_dir()
.join("tuillem.db")
.to_string_lossy()
.into_owned()
})
.unwrap_or_else(|| "tuillem.db".to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UiConfig {
#[serde(default = "default_sidebar_width")]
pub sidebar_width: u16,
#[serde(default)]
pub show_thinking: bool,
#[serde(default = "default_true")]
pub show_token_usage: bool,
#[serde(default = "default_true")]
pub mouse: bool,
#[serde(default)]
pub show_stats: bool,
#[serde(default = "default_layout")]
pub layout: String,
#[serde(default = "default_date_format")]
pub date_format: String,
#[serde(default = "default_scroll_lines")]
pub scroll_lines: u16,
#[serde(default = "default_command_prefix")]
pub command_prefix: String,
#[serde(default = "default_true")]
pub nerd_fonts: bool,
#[serde(default = "default_color_mode")]
pub color_mode: String,
#[serde(default = "default_stream_visible_lines")]
pub stream_visible_lines: u16,
}
fn default_stream_visible_lines() -> u16 {
10
}
fn default_color_mode() -> String {
"auto".to_string()
}
fn default_command_prefix() -> String {
"/".to_string()
}
fn default_scroll_lines() -> u16 {
5
}
fn default_date_format() -> String {
"dd/mm/yyyy".to_string()
}
impl Default for UiConfig {
fn default() -> Self {
Self {
sidebar_width: 30,
show_thinking: false,
show_token_usage: true,
mouse: true,
show_stats: false,
layout: default_layout(),
date_format: default_date_format(),
scroll_lines: default_scroll_lines(),
command_prefix: default_command_prefix(),
nerd_fonts: true,
color_mode: default_color_mode(),
stream_visible_lines: default_stream_visible_lines(),
}
}
}
fn default_layout() -> String {
"loose".to_string()
}
fn default_sidebar_width() -> u16 {
30
}
fn default_true() -> bool {
true
}
fn default_editor() -> String {
std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string())
}
fn default_theme() -> String {
"dark".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_editor")]
pub editor: String,
#[serde(default)]
pub keybindings: KeybindingPreset,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default)]
pub themes: HashMap<String, ThemeColors>,
#[serde(default)]
pub providers: Vec<ProviderConfig>,
#[serde(default)]
pub defaults: DefaultsConfig,
#[serde(default)]
pub tools: Vec<ToolConfig>,
#[serde(default)]
pub database: DatabaseConfig,
#[serde(default)]
pub ui: UiConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
editor: default_editor(),
keybindings: KeybindingPreset::Default,
theme: "dark".to_string(),
themes: HashMap::new(),
providers: Vec::new(),
defaults: DefaultsConfig::default(),
tools: Vec::new(),
database: DatabaseConfig::default(),
ui: UiConfig::default(),
}
}
}
impl Config {
pub fn from_yaml(yaml: &str) -> Result<Config, ConfigError> {
let expanded = expand_env_vars(yaml);
let config: Config = serde_yaml::from_str(&expanded)?;
config.validate()?;
Ok(config)
}
pub fn from_file(path: &Path) -> Result<Config, ConfigError> {
let contents = std::fs::read_to_string(path)?;
Self::from_yaml(&contents)
}
pub fn default_path() -> PathBuf {
ProjectDirs::from("com", "tuillem", "tuillem")
.map(|dirs| dirs.config_dir().join("config.yaml"))
.unwrap_or_else(|| PathBuf::from("config.yaml"))
}
pub fn validate(&self) -> Result<(), ConfigError> {
for provider in &self.providers {
let needs_key = matches!(
provider.provider_type,
ProviderType::Anthropic | ProviderType::Openai | ProviderType::Openrouter
);
if needs_key && provider.api_key.is_none() {
return Err(ConfigError::Validation(format!(
"Provider '{}' requires an api_key",
provider.name
)));
}
}
if let Some(ref default_provider) = self.defaults.provider {
let exists = self.providers.iter().any(|p| &p.name == default_provider);
if !exists {
return Err(ConfigError::Validation(format!(
"Default provider '{}' not found in providers list",
default_provider
)));
}
}
Ok(())
}
}
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn expand_env_vars(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' && chars.peek() == Some(&'{') {
chars.next(); let mut var_expr = String::new();
let mut found_close = false;
for ch in chars.by_ref() {
if ch == '}' {
found_close = true;
break;
}
var_expr.push(ch);
}
if found_close {
let (var_name, default_val) = if let Some(pos) = var_expr.find(":-") {
(&var_expr[..pos], Some(&var_expr[pos + 2..]))
} else {
(var_expr.as_str(), None)
};
match std::env::var(var_name) {
Ok(val) if !val.is_empty() => result.push_str(&val),
_ => {
if let Some(def) = default_val {
result.push_str(def);
} else {
result.push_str(&format!("${{{}}}", var_expr));
}
}
}
} else {
result.push('$');
result.push('{');
result.push_str(&var_expr);
}
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_minimal_config() {
let config = Config::from_yaml("{}").expect("should parse empty config");
assert_eq!(config.theme, "dark");
assert_eq!(config.keybindings, KeybindingPreset::Default);
assert!(config.providers.is_empty());
assert!(config.tools.is_empty());
assert_eq!(config.ui.sidebar_width, 30);
assert!(!config.ui.show_thinking);
assert!(config.ui.show_token_usage);
assert!(config.ui.mouse);
assert_eq!(config.ui.layout, "loose");
}
#[test]
fn test_full_config() {
let yaml = r##"
editor: nvim
keybindings: vim
theme: dark
themes:
dark:
bg: "#1e1e2e"
fg: "#cdd6f4"
providers:
- name: anthropic
provider_type: anthropic
api_key: "sk-ant-test"
default_model: claude-sonnet-4-20250514
models:
- claude-sonnet-4-20250514
- claude-3-haiku-20240307
- name: local
provider_type: ollama
base_url: "http://localhost:11434"
models:
- llama3
defaults:
provider: anthropic
model: claude-sonnet-4-20250514
system_prompt: "You are a helpful assistant."
tools:
- name: grep_tool
description: "Search files"
command: "grep -rn"
timeout: "10s"
confirm: true
env:
LANG: "en_US.UTF-8"
database:
path: "/tmp/test.db"
ui:
sidebar_width: 40
show_thinking: true
show_token_usage: false
mouse: false
"##;
let config = Config::from_yaml(yaml).expect("should parse full config");
assert_eq!(config.editor, "nvim");
assert_eq!(config.keybindings, KeybindingPreset::Vim);
assert_eq!(config.theme, "dark");
assert_eq!(config.providers.len(), 2);
assert_eq!(config.providers[0].name, "anthropic");
assert_eq!(config.providers[0].api_key.as_deref(), Some("sk-ant-test"));
assert_eq!(config.providers[1].provider_type, ProviderType::Ollama);
assert_eq!(config.defaults.provider.as_deref(), Some("anthropic"));
assert_eq!(config.tools.len(), 1);
assert_eq!(config.tools[0].timeout, "10s");
assert!(config.tools[0].confirm);
assert_eq!(config.database.path, "/tmp/test.db");
assert_eq!(config.ui.sidebar_width, 40);
assert!(config.ui.show_thinking);
assert!(!config.ui.show_token_usage);
assert!(!config.ui.mouse);
let dark = config.themes.get("dark").expect("dark theme should exist");
assert_eq!(dark.bg.as_deref(), Some("#1e1e2e"));
}
#[test]
fn test_validation_missing_api_key() {
let yaml = "
providers:
- name: anthropic
provider_type: anthropic
";
let result = Config::from_yaml(yaml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("requires an api_key"),
"Expected api_key error, got: {err}",
);
}
#[test]
fn test_validation_invalid_default_provider() {
let yaml = "
providers:
- name: anthropic
provider_type: anthropic
api_key: sk-test
defaults:
provider: nonexistent
";
let result = Config::from_yaml(yaml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("not found in providers list"),
"Expected provider-not-found error, got: {err}",
);
}
#[test]
fn test_ollama_no_api_key_required() {
let yaml = "
providers:
- name: local
provider_type: ollama
base_url: http://localhost:11434
models:
- llama3
";
let config = Config::from_yaml(yaml).expect("ollama should not require api_key");
assert_eq!(config.providers.len(), 1);
assert_eq!(config.providers[0].provider_type, ProviderType::Ollama);
assert!(config.providers[0].api_key.is_none());
}
#[test]
fn test_env_var_expansion() {
unsafe { std::env::set_var("TUILLEM_TEST_KEY", "sk-test-12345") };
let result = expand_env_vars("api_key: ${TUILLEM_TEST_KEY}");
assert_eq!(result, "api_key: sk-test-12345");
unsafe { std::env::remove_var("TUILLEM_TEST_KEY") };
}
#[test]
fn test_env_var_default() {
unsafe { std::env::remove_var("TUILLEM_UNSET_VAR") };
let result = expand_env_vars("key: ${TUILLEM_UNSET_VAR:-fallback_value}");
assert_eq!(result, "key: fallback_value");
}
#[test]
fn test_env_var_unset_no_default() {
unsafe { std::env::remove_var("TUILLEM_MISSING") };
let result = expand_env_vars("key: ${TUILLEM_MISSING}");
assert_eq!(result, "key: ${TUILLEM_MISSING}");
}
#[test]
fn test_env_var_in_config() {
unsafe { std::env::set_var("TUILLEM_TEST_API", "sk-ant-real-key") };
let yaml = r#"
providers:
- name: anthropic
provider_type: anthropic
api_key: "${TUILLEM_TEST_API}"
models:
- claude-sonnet-4-20250514
"#;
let config = Config::from_yaml(yaml).unwrap();
assert_eq!(
config.providers[0].api_key.as_deref(),
Some("sk-ant-real-key")
);
unsafe { std::env::remove_var("TUILLEM_TEST_API") };
}
}