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_smart_wait")]
pub smart_wait: bool,
#[serde(default)]
pub server: HashMap<String, ServerConfig>,
#[serde(default)]
pub tools: ToolsConfig,
}
const fn default_smart_wait() -> bool {
true
}
#[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>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct ToolsConfig {
#[serde(default)]
pub run: Option<RunToolConfig>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct RunToolConfig {
#[serde(default)]
pub allowed: Vec<String>,
#[serde(flatten)]
pub languages: HashMap<String, LanguageCommands>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct LanguageCommands {
pub allowed: Vec<String>,
}
const fn default_idle_timeout() -> u64 {
300
}
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("smart_wait", true)?;
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"));
let config = builder.build().context("Failed to build configuration")?;
config
.try_deserialize()
.context("Failed to deserialize configuration")
}
}
#[cfg(test)]
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
smart_wait = false
[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!(!config.smart_wait);
assert_eq!(
config
.server
.get("rust")
.context("missing rust server")?
.command,
"rust-analyzer-local"
);
Ok(())
}
#[test]
fn test_config_without_tools_run() -> Result<()> {
let config: Config = toml::from_str("idle_timeout = 100\n")?;
assert!(
config.tools.run.is_none(),
"run tool should not be configured"
);
Ok(())
}
#[test]
fn test_config_with_tools_run_basic() -> Result<()> {
let config: Config = toml::from_str(
r#"
[tools.run]
allowed = ["git", "make"]
"#,
)?;
let run = config.tools.run.context("run tool should be configured")?;
assert_eq!(run.allowed, vec!["git", "make"]);
assert!(run.languages.is_empty(), "No language configs expected");
Ok(())
}
#[test]
fn test_config_with_tools_run_languages() -> Result<()> {
let config: Config = toml::from_str(
r#"
[tools.run]
allowed = ["git"]
[tools.run.python]
allowed = ["python", "pytest", "uv"]
[tools.run.rust]
allowed = ["cargo"]
"#,
)?;
let run = config.tools.run.context("run tool should be configured")?;
assert_eq!(run.allowed, vec!["git"]);
assert_eq!(run.languages.len(), 2);
let python = run.languages.get("python").context("missing python")?;
assert_eq!(python.allowed, vec!["python", "pytest", "uv"]);
let rust = run.languages.get("rust").context("missing rust")?;
assert_eq!(rust.allowed, vec!["cargo"]);
Ok(())
}
#[test]
fn test_config_with_tools_run_wildcard() -> Result<()> {
let config: Config = toml::from_str(
r#"
[tools.run]
allowed = ["*"]
"#,
)?;
let run = config.tools.run.context("run tool should be configured")?;
assert_eq!(run.allowed, vec!["*"]);
Ok(())
}
}