use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathfinderConfig {
#[serde(default)]
pub lsp: HashMap<String, LspConfig>,
#[serde(default)]
pub sandbox: SandboxConfig,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub repo_map: RepoMapConfig,
#[serde(default = "default_log_level")]
pub log_level: String,
}
impl Default for PathfinderConfig {
fn default() -> Self {
Self {
lsp: HashMap::new(),
sandbox: SandboxConfig::default(),
search: SearchConfig::default(),
repo_map: RepoMapConfig::default(),
log_level: default_log_level(),
}
}
}
impl PathfinderConfig {
pub async fn load(workspace_root: &Path) -> Result<Self, ConfigError> {
let config_path = workspace_root.join("pathfinder.config.json");
if !config_path.exists() {
tracing::debug!("No pathfinder.config.json found, using defaults");
return Ok(Self::default());
}
let content =
tokio::fs::read_to_string(&config_path)
.await
.map_err(|e| ConfigError::ReadFailed {
path: config_path.clone(),
source: e,
})?;
let config: Self =
serde_json::from_str(&content).map_err(|e| ConfigError::ParseFailed {
path: config_path,
source: e,
})?;
tracing::info!("Loaded configuration from pathfinder.config.json");
Ok(config)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default = "default_idle_timeout")]
pub idle_timeout_minutes: u64,
#[serde(default)]
pub settings: serde_json::Value,
#[serde(default)]
pub root_override: Option<String>,
#[serde(default)]
pub typescript_plugins: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SandboxConfig {
#[serde(default)]
pub additional_deny: Vec<String>,
#[serde(default)]
pub allow_override: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfig {
#[serde(default = "default_max_results")]
pub max_results: usize,
#[serde(default = "default_filter_mode")]
pub default_filter_mode: String,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
max_results: default_max_results(),
default_filter_mode: default_filter_mode(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoMapConfig {
#[serde(default = "default_max_tokens")]
pub max_tokens: usize,
#[serde(default = "default_token_method")]
pub token_method: String,
}
impl Default for RepoMapConfig {
fn default() -> Self {
Self {
max_tokens: default_max_tokens(),
token_method: default_token_method(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file {}: {source}", path.display())]
ReadFailed {
path: std::path::PathBuf,
source: std::io::Error,
},
#[error("failed to parse config file {}: {source}", path.display())]
ParseFailed {
path: std::path::PathBuf,
source: serde_json::Error,
},
}
fn default_log_level() -> String {
"info".to_owned()
}
const fn default_idle_timeout() -> u64 {
15
}
const fn default_max_results() -> usize {
50
}
fn default_filter_mode() -> String {
"code_only".to_owned()
}
const fn default_max_tokens() -> usize {
16_000
}
fn default_token_method() -> String {
"char_div_4".to_owned()
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_default_config() {
let config = PathfinderConfig::default();
assert_eq!(config.log_level, "info");
assert_eq!(config.search.max_results, 50);
assert_eq!(config.repo_map.max_tokens, 16_000);
assert!(config.lsp.is_empty());
}
#[test]
fn test_config_deserialization() {
let json = r#"{
"lsp": {
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"],
"idle_timeout_minutes": 30
}
},
"sandbox": {
"additional_deny": ["*.generated.ts"],
"allow_override": [".env.example"]
},
"search": {
"max_results": 100
},
"log_level": "debug"
}"#;
let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
assert_eq!(config.log_level, "debug");
assert_eq!(config.search.max_results, 100);
assert!(config.lsp.contains_key("typescript"));
let ts_config = &config.lsp["typescript"];
assert_eq!(ts_config.command, "typescript-language-server");
assert_eq!(ts_config.idle_timeout_minutes, 30);
}
#[test]
fn test_partial_config_uses_defaults() {
let json = r#"{ "log_level": "warn" }"#;
let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
assert_eq!(config.log_level, "warn");
assert_eq!(config.search.max_results, 50);
assert_eq!(config.repo_map.max_tokens, 16_000);
}
#[tokio::test]
async fn test_load_missing_config_returns_defaults() {
let temp = tempdir().expect("create tempdir");
let config = PathfinderConfig::load(temp.path())
.await
.expect("should return defaults");
assert_eq!(config.log_level, "info");
}
#[tokio::test]
async fn test_load_invalid_json_returns_error() {
let temp = std::env::temp_dir().join("pathfinder_test_bad_json");
let _ = std::fs::create_dir_all(&temp);
std::fs::write(temp.join("pathfinder.config.json"), "not json").expect("should write");
let result = PathfinderConfig::load(&temp).await;
assert!(result.is_err());
assert!(matches!(result, Err(ConfigError::ParseFailed { .. })));
let _ = std::fs::remove_dir_all(&temp);
}
#[tokio::test]
async fn test_load_valid_config_from_file() {
let temp = tempdir().expect("create tempdir");
let config_json = r#"{ "log_level": "trace", "idle_timeout": 30 }"#;
std::fs::write(temp.path().join("pathfinder.config.json"), config_json)
.expect("should write config");
let config = PathfinderConfig::load(temp.path())
.await
.expect("should load valid config");
assert_eq!(config.log_level, "trace");
assert_eq!(config.search.max_results, 50);
}
#[test]
fn test_default_idle_timeout_value() {
assert_eq!(default_idle_timeout(), 15);
}
#[test]
fn test_typescript_plugins_defaults_to_empty() {
let config: LspConfig =
serde_json::from_str(r#"{ "command": "tsserver" }"#).expect("should parse");
assert!(config.typescript_plugins.is_empty());
}
#[test]
fn test_typescript_plugins_deserialization() {
let json = r#"{
"lsp": {
"typescript": {
"command": "typescript-language-server",
"typescript_plugins": ["@vue/typescript-plugin", "@angular/language-service"]
}
}
}"#;
let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
let ts_config = &config.lsp["typescript"];
assert_eq!(ts_config.typescript_plugins.len(), 2);
assert_eq!(ts_config.typescript_plugins[0], "@vue/typescript-plugin");
assert_eq!(ts_config.typescript_plugins[1], "@angular/language-service");
}
#[test]
fn test_java_lsp_config_section() {
let json = r#"{
"lsp": {
"java": {
"command": "jdtls",
"args": ["--jvm-arg=-Xmx2G"],
"idle_timeout_minutes": 20,
"settings": {
"java": {
"format": { "enabled": true },
"import": {
"gradle": { "enabled": true },
"maven": { "enabled": true }
}
}
}
}
}
}"#;
let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
assert!(
config.lsp.contains_key("java"),
"[lsp.java] key must be present"
);
let java_config = &config.lsp["java"];
assert_eq!(java_config.command, "jdtls", "command must be jdtls");
assert_eq!(
java_config.args,
vec!["--jvm-arg=-Xmx2G"],
"args must round-trip"
);
assert_eq!(
java_config.idle_timeout_minutes, 20,
"idle timeout must be 20"
);
let settings = &java_config.settings;
assert!(!settings.is_null(), "settings must not be null");
assert!(
settings.get("java").is_some(),
"settings.java key must be present"
);
}
#[test]
fn test_java_lsp_config_minimal() {
let json = r#"{
"lsp": {
"java": {
"command": "jdtls"
}
}
}"#;
let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
let java_config = &config.lsp["java"];
assert_eq!(java_config.command, "jdtls");
assert!(java_config.args.is_empty(), "args should default to empty");
assert_eq!(
java_config.idle_timeout_minutes,
default_idle_timeout(),
"idle_timeout_minutes must default to {}",
default_idle_timeout()
);
assert!(
java_config.settings.is_null(),
"settings should default to null"
);
assert!(
java_config.root_override.is_none(),
"root_override should default to None"
);
assert!(
java_config.typescript_plugins.is_empty(),
"typescript_plugins should default to empty"
);
}
#[test]
fn test_java_and_typescript_lsp_configs_coexist() {
let json = r#"{
"lsp": {
"java": {
"command": "jdtls",
"idle_timeout_minutes": 30
},
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"]
}
}
}"#;
let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
assert_eq!(
config.lsp.len(),
2,
"both java and typescript must be present"
);
assert_eq!(config.lsp["java"].command, "jdtls");
assert_eq!(config.lsp["java"].idle_timeout_minutes, 30);
assert_eq!(
config.lsp["typescript"].command,
"typescript-language-server"
);
assert_eq!(config.lsp["typescript"].args, vec!["--stdio"]);
}
}