mod language;
mod parse;
mod server;
mod validate;
use std::collections::HashMap;
use anyhow::Result;
use serde::Deserialize;
pub use language::LanguageConfig;
pub use parse::{SERVER_DEF_KEYS, config_sources};
pub use server::ServerDef;
#[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 language: HashMap<String, LanguageConfig>,
#[serde(default)]
pub server: HashMap<String, ServerDef>,
#[serde(default)]
pub icons: IconConfig,
#[serde(default)]
pub tui: TuiConfig,
}
#[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_sed: Option<String>,
pub ls_active: Option<String>,
pub ls_inactive: Option<String>,
pub proto_ok: Option<String>,
pub proto_error: Option<String>,
pub cancelled: Option<String>,
pub log_info: 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(crate) const fn default_idle_timeout() -> u64 {
300
}
pub(crate) const fn default_log_retention_days() -> i64 {
7
}
impl Config {
pub fn load() -> Result<Self> {
parse::load()
}
pub fn check() -> Result<()> {
let _ = Self::load()?;
Ok(())
}
#[cfg(test)]
fn load_from_sources(sources: &[std::path::PathBuf]) -> Result<Self> {
parse::load_from_sources(sources)
}
fn merge(&mut self, other: Self) {
parse::merge(self, other);
}
fn apply_env_overrides(&mut self) {
parse::apply_env_overrides(self);
}
fn apply_default_inherits(&mut self) {
parse::apply_default_inherits(self);
}
#[must_use]
pub fn validate(&self) -> Vec<String> {
validate::validate(self)
}
#[must_use]
pub fn resolve_language<'a>(&'a self, key: &'a str) -> Option<(&'a str, LanguageConfig)> {
language::resolve_language(&self.language, key)
}
}
impl Default for Config {
fn default() -> Self {
Self {
idle_timeout: default_idle_timeout(),
log_retention_days: default_log_retention_days(),
language: HashMap::new(),
server: HashMap::new(),
icons: IconConfig::default(),
tui: TuiConfig::default(),
}
}
}
#[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() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
idle_timeout = 42
[server.rust-analyzer]
command = "rust-analyzer-local"
[language.rust]
servers = ["rust-analyzer"]
"#,
)?;
let config = Config::load_from_sources(&[config_path])?;
assert_eq!(config.idle_timeout, 42);
assert_eq!(
config
.language
.get("rust")
.expect("rust language config")
.servers,
vec!["rust-analyzer"],
);
Ok(())
}
#[test]
fn test_old_server_key_hard_error() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.rust]
command = "rust-analyzer"
"#,
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
let err = format!("{:#}", result.expect_err("should error"));
assert!(
err.contains("deprecated"),
"error should mention deprecated: {err}",
);
}
#[test]
fn test_server_def_parsing() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.rust-analyzer]
command = "rust-analyzer"
args = ["--log-level", "info"]
[server.clangd]
command = "clangd"
args = ["--background-index"]
settings = { checkOnSave = true }
[language.rust]
servers = ["rust-analyzer"]
[language.c]
servers = ["clangd"]
"#,
)?;
let config = Config::load_from_sources(&[config_path])?;
assert!(config.language.contains_key("rust"));
assert_eq!(config.server.len(), 2);
let ra = config
.server
.get("rust-analyzer")
.expect("rust-analyzer server def");
assert_eq!(ra.command, "rust-analyzer");
assert_eq!(ra.args, vec!["--log-level", "info"]);
let clangd = config.server.get("clangd").expect("clangd server def");
assert_eq!(clangd.command, "clangd");
assert_eq!(clangd.args, vec!["--background-index"]);
assert!(clangd.settings.is_some());
Ok(())
}
#[test]
fn test_both_server_and_language_valid() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.rust-analyzer]
command = "rust-analyzer"
[language.rust]
servers = ["rust-analyzer"]
"#,
)?;
let config = Config::load_from_sources(&[config_path])?;
assert!(config.server.contains_key("rust-analyzer"));
assert!(config.language.contains_key("rust"));
Ok(())
}
#[test]
fn test_server_def_merge() -> anyhow::Result<()> {
let dir = tempdir()?;
let source1 = dir.path().join("source1.toml");
fs::write(
&source1,
r#"
[server.rust-analyzer]
command = "rust-analyzer"
[server.clangd]
command = "clangd"
args = ["--background-index"]
[language.rust]
servers = ["rust-analyzer"]
"#,
)?;
let source2 = dir.path().join("source2.toml");
fs::write(
&source2,
r#"
[server.rust-analyzer]
command = "rust-analyzer"
[server.clangd]
command = "clangd"
args = ["--background-index", "--clang-tidy"]
settings = { checkOnSave = true }
[language.rust]
servers = ["rust-analyzer"]
"#,
)?;
let config = Config::load_from_sources(&[source1, source2])?;
let clangd = config.server.get("clangd").expect("clangd server def");
assert_eq!(clangd.args, vec!["--background-index", "--clang-tidy"]);
assert!(clangd.settings.is_some());
Ok(())
}
#[test]
fn test_server_def_validation() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.rust-analyzer]
command = "rust-analyzer"
[server.bad-server]
command = ""
[language.rust]
servers = ["rust-analyzer"]
"#,
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
let err = format!("{:#}", result.expect_err("should error"));
assert!(
err.contains("empty") && err.contains("command"),
"error should mention empty command: {err}",
);
}
#[test]
fn test_inherit_resolves() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.tsserver]
command = "typescript-language-server"
args = ["--stdio"]
[language.typescript]
servers = ["tsserver"]
[language.typescriptreact]
inherit = "typescript"
"#,
)?;
let config = Config::load_from_sources(&[config_path])?;
let (canonical, resolved) = config
.resolve_language("typescriptreact")
.expect("should resolve");
assert_eq!(canonical, "typescript");
assert_eq!(resolved.servers, vec!["tsserver"]);
Ok(())
}
#[test]
fn test_inherit_with_override() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.tsserver]
command = "typescript-language-server"
args = ["--stdio"]
[language.typescript]
servers = ["tsserver"]
min_severity = "warning"
[language.typescriptreact]
inherit = "typescript"
min_severity = "error"
"#,
)?;
let config = Config::load_from_sources(&[config_path])?;
let (_, resolved) = config
.resolve_language("typescriptreact")
.expect("should resolve");
assert_eq!(resolved.min_severity.as_deref(), Some("error"));
assert_eq!(resolved.servers, vec!["tsserver"]);
Ok(())
}
#[test]
fn test_inherit_missing_target() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[language.typescriptreact]
inherit = "typescript"
"#,
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
}
#[test]
fn test_inherit_chain_rejected() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.server-a]
command = "server-a"
[language.a]
servers = ["server-a"]
[language.b]
inherit = "a"
[language.c]
inherit = "b"
"#,
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
let err = format!("{:#}", result.expect_err("should error"));
assert!(err.contains("chains"), "error should mention chains: {err}",);
}
#[test]
fn test_inherit_cycle_rejected() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[language.a]
inherit = "b"
[language.b]
inherit = "a"
"#,
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
}
#[test]
fn test_concrete_without_servers_rejected() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[language.rust]
min_severity = "warning"
"#,
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
let err = format!("{:#}", result.expect_err("should error"));
assert!(
err.contains("servers"),
"error should mention servers: {err}",
);
}
#[test]
fn test_default_inherits_applied() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.tsserver]
command = "typescript-language-server"
args = ["--stdio"]
[language.typescript]
servers = ["tsserver"]
"#,
)?;
let config = Config::load_from_sources(&[config_path])?;
assert!(config.language.contains_key("typescriptreact"));
let (canonical, _) = config
.resolve_language("typescriptreact")
.expect("should resolve");
assert_eq!(canonical, "typescript");
Ok(())
}
#[test]
fn test_user_defined_overrides_default_inherit() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.tsserver]
command = "typescript-language-server"
args = ["--stdio"]
[server.custom-tsx]
command = "custom-tsx-server"
[language.typescript]
servers = ["tsserver"]
[language.typescriptreact]
servers = ["custom-tsx"]
"#,
)?;
let config = Config::load_from_sources(&[config_path])?;
let tsx = config
.language
.get("typescriptreact")
.expect("typescriptreact config");
assert!(tsx.inherit.is_none());
assert_eq!(tsx.servers, vec!["custom-tsx"]);
Ok(())
}
#[test]
fn test_empty_config() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(&config_path, "")?;
let config = Config::load_from_sources(&[config_path])?;
assert_eq!(config.idle_timeout, 300);
assert_eq!(config.log_retention_days, 7);
assert!(config.language.is_empty());
assert!(config.server.is_empty());
Ok(())
}
#[test]
fn test_merge_later_source_overrides() -> anyhow::Result<()> {
let dir = tempdir()?;
let local_config_path = dir.path().join(".catenary.toml");
fs::write(
&local_config_path,
r#"
idle_timeout = 42
[server.rust-analyzer]
command = "rust-analyzer-local"
[language.rust]
servers = ["rust-analyzer"]
"#,
)?;
let explicit_path = dir.path().join("explicit.toml");
fs::write(
&explicit_path,
r"
idle_timeout = 99
",
)?;
let config = Config::load_from_sources(&[local_config_path, explicit_path])?;
assert_eq!(config.idle_timeout, 99);
assert!(config.language.contains_key("rust"));
Ok(())
}
#[test]
fn test_new_format_roundtrip() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.rust-analyzer]
command = "rust-analyzer"
args = ["--log-level", "info"]
[server.clangd]
command = "clangd"
args = ["--background-index"]
[language.rust]
servers = ["rust-analyzer"]
min_severity = "warning"
[language.c]
servers = ["clangd"]
[language.cpp]
inherit = "c"
"#,
)?;
let config = Config::load_from_sources(&[config_path])?;
let ra = config
.server
.get("rust-analyzer")
.expect("rust-analyzer server def");
assert_eq!(ra.command, "rust-analyzer");
assert_eq!(ra.args, vec!["--log-level", "info"]);
let rust = config.language.get("rust").expect("rust config");
assert_eq!(rust.servers, vec!["rust-analyzer"]);
assert_eq!(rust.min_severity.as_deref(), Some("warning"));
let c = config.language.get("c").expect("c config");
assert_eq!(c.servers, vec!["clangd"]);
let (canonical, resolved) = config.resolve_language("cpp").expect("should resolve");
assert_eq!(canonical, "c");
assert_eq!(resolved.servers, vec!["clangd"]);
Ok(())
}
#[test]
fn test_inline_command_hard_error() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[language.rust]
command = "rust-analyzer"
"#,
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
let err = format!("{:#}", result.expect_err("should error"));
assert!(
err.contains("command") && err.contains("[server.*]"),
"error should mention server definition migration: {err}",
);
}
#[test]
fn test_undefined_server_ref() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[language.rust]
servers = ["nonexistent-server"]
"#,
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
let err = format!("{:#}", result.expect_err("should error"));
assert!(
err.contains("nonexistent-server"),
"error should mention the undefined server: {err}",
);
}
#[test]
fn test_concrete_empty_servers() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r"
[language.rust]
servers = []
",
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
let err = format!("{:#}", result.expect_err("should error"));
assert!(
err.contains("servers"),
"error should mention servers: {err}",
);
}
#[test]
fn test_inherit_with_servers_error() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.tsserver]
command = "typescript-language-server"
[language.typescript]
servers = ["tsserver"]
[language.typescriptreact]
inherit = "typescript"
servers = ["tsserver"]
"#,
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
let err = format!("{:#}", result.expect_err("should error"));
assert!(
err.contains("inherit") && err.contains("servers"),
"error should mention inherit + servers conflict: {err}",
);
}
#[test]
fn test_parse_server_specs_single() {
let results = parse::parse_server_specs("rust:rust-analyzer --log-level info");
assert_eq!(results.len(), 1);
let (lang, server_def, lang_config) = &results[0];
assert_eq!(lang, "rust");
assert_eq!(server_def.command, "rust-analyzer");
assert_eq!(server_def.args, vec!["--log-level", "info"]);
assert_eq!(lang_config.servers, vec!["rust"]);
}
#[test]
fn test_parse_server_specs_multiple() {
let results =
parse::parse_server_specs("rust:rust-analyzer;python:pyright --stdio;c:clangd");
assert_eq!(results.len(), 3);
assert_eq!(results[0].0, "rust");
assert_eq!(results[0].1.command, "rust-analyzer");
assert!(results[0].1.args.is_empty());
assert_eq!(results[1].0, "python");
assert_eq!(results[1].1.command, "pyright");
assert_eq!(results[1].1.args, vec!["--stdio"]);
assert_eq!(results[2].0, "c");
assert_eq!(results[2].1.command, "clangd");
}
#[test]
fn test_parse_server_specs_empty_and_whitespace() {
let results = parse::parse_server_specs(" ; ;rust:ra; ");
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, "rust");
assert_eq!(results[0].1.command, "ra");
}
#[test]
fn test_resolve_language_servers() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.tsserver]
command = "typescript-language-server"
[language.typescript]
servers = ["tsserver"]
[language.typescriptreact]
inherit = "typescript"
"#,
)?;
let config = Config::load_from_sources(&[config_path])?;
let (key, resolved) = config
.resolve_language("typescript")
.expect("should resolve");
assert_eq!(key, "typescript");
assert_eq!(resolved.servers, vec!["tsserver"]);
let (key, resolved) = config
.resolve_language("typescriptreact")
.expect("should resolve");
assert_eq!(key, "typescript");
assert_eq!(resolved.servers, vec!["tsserver"]);
Ok(())
}
#[test]
fn test_config_check_valid() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[server.rust-analyzer]
command = "rust-analyzer"
[language.rust]
servers = ["rust-analyzer"]
"#,
)?;
let config = Config::load_from_sources(&[config_path]);
assert!(config.is_ok());
Ok(())
}
#[test]
fn test_config_check_invalid_old_format() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[language.rust]
command = "rust-analyzer"
"#,
)
.expect("write config");
let result = Config::load_from_sources(&[config_path]);
assert!(result.is_err());
let err = format!("{:#}", result.expect_err("should error"));
assert!(
err.contains("[server.*]"),
"error should mention server migration: {err}",
);
}
#[test]
fn test_config_check_fast() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "").expect("write config");
let start = std::time::Instant::now();
let _ = Config::load_from_sources(&[config_path]);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(50),
"config check took {elapsed:?}, expected < 50ms",
);
}
}