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)]
pub validation: ValidationConfig,
#[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(),
validation: ValidationConfig::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>,
}
#[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, Clone, Serialize, Deserialize)]
pub struct ValidationConfig {
#[serde(default = "default_validation_scope")]
pub scope: String,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
scope: default_validation_scope(),
}
}
}
#[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()
}
fn default_validation_scope() -> String {
"workspace_wide".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_eq!(config.validation.scope, "workspace_wide");
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);
}
}