use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
#[serde(default = "default_idle_timeout")]
pub idle_timeout: u64,
#[serde(default = "default_log_retention_days")]
pub log_retention_days: i64,
#[serde(default)]
pub server: HashMap<String, ServerConfig>,
#[serde(default)]
pub icons: IconConfig,
#[serde(default)]
pub tui: TuiConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServerConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub initialization_options: Option<serde_json::Value>,
#[serde(default)]
pub min_severity: Option<String>,
#[serde(default)]
pub settings: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "lowercase")]
pub enum IconPreset {
#[default]
Unicode,
Nerd,
Emoji,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct IconConfig {
#[serde(default)]
pub preset: IconPreset,
pub diag_error: Option<String>,
pub diag_warn: Option<String>,
pub diag_info: Option<String>,
pub diag_ok: Option<String>,
pub tool_search: Option<String>,
pub tool_glob: Option<String>,
pub tool_default: Option<String>,
pub workspace_open: Option<String>,
pub workspace_closed: Option<String>,
pub pinned: Option<String>,
pub progress: Option<String>,
pub session_started: Option<String>,
pub session_shutdown: Option<String>,
pub server_state: Option<String>,
pub tool_result: Option<String>,
pub tool_result_sep: Option<String>,
pub tool_sed: Option<String>,
pub ls_active: Option<String>,
pub ls_inactive: Option<String>,
pub spinner_grow: Option<Vec<String>>,
pub spinner_cycle: Option<Vec<String>>,
pub spinner_done: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct TuiConfig {
#[serde(default = "default_true")]
pub auto_add_sessions: bool,
#[serde(default = "default_sessions_width")]
pub sessions_width: f64,
#[serde(default)]
pub focus_follows_mouse: bool,
#[serde(default)]
pub capture_tool_output: bool,
}
impl Default for TuiConfig {
fn default() -> Self {
Self {
auto_add_sessions: true,
sessions_width: 0.25,
focus_follows_mouse: false,
capture_tool_output: false,
}
}
}
const fn default_true() -> bool {
true
}
const fn default_sessions_width() -> f64 {
0.25
}
pub struct ReplaceConfig {
pub budget: u32,
}
impl Default for ReplaceConfig {
fn default() -> Self {
Self { budget: 4000 }
}
}
const fn default_idle_timeout() -> u64 {
300
}
const fn default_log_retention_days() -> i64 {
7
}
impl Config {
pub fn load(explicit_file: Option<PathBuf>) -> Result<Self> {
let mut builder = config::Config::builder();
builder = builder.set_default("idle_timeout", 300)?;
builder = builder.set_default("log_retention_days", 7)?;
builder = builder.set_default("icons.preset", "unicode")?;
if let Some(config_dir) = dirs::config_dir() {
let config_path = config_dir.join("catenary").join("config.toml");
if config_path.exists() {
builder = builder.add_source(config::File::from(config_path));
}
}
if let Ok(cwd) = std::env::current_dir() {
let mut current = Some(cwd.as_path());
while let Some(path) = current {
let config_path = path.join(".catenary.toml");
if config_path.exists() {
builder = builder.add_source(config::File::from(config_path));
break;
}
current = path.parent();
}
}
if let Some(path) = explicit_file {
builder = builder.add_source(config::File::from(path));
}
builder = builder.add_source(config::Environment::with_prefix("CATENARY").separator("__"));
let config = builder.build().context("Failed to build configuration")?;
config
.try_deserialize()
.context("Failed to deserialize configuration")
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_config_load_local() -> Result<()> {
let dir = tempdir()?;
let local_config_path = dir.path().join(".catenary.toml");
fs::write(
&local_config_path,
r#"
idle_timeout = 42
[server.rust]
command = "rust-analyzer-local"
"#,
)?;
let original_dir = std::env::current_dir()?;
std::env::set_current_dir(dir.path())?;
let config = Config::load(None)?;
std::env::set_current_dir(original_dir)?;
assert_eq!(config.idle_timeout, 42);
assert_eq!(
config
.server
.get("rust")
.expect("rust server config")
.command,
"rust-analyzer-local"
);
Ok(())
}
}