use crate::config_model::ServerConfig;
use crate::env_loader::{load_allowed_dirs_from_env, load_denied_dirs_from_env};
use crate::persistence;
use crate::persistence::{backup_path, SaveRequest};
use crate::system_info::{ClientInfo, get_system_info, SystemInfo};
use crate::watcher::ConfigWatcher;
use crate::ConfigValueExt;
use kodegen_config::KodegenConfig;
use kodegen_mcp_schema::McpError;
use kodegen_mcp_schema::config::ConfigValue;
use parking_lot::RwLock;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::atomic::{AtomicU64, AtomicBool, Ordering};
use std::time::Instant;
use anyhow::anyhow;
static SYSTEM_INFO_CACHE: OnceLock<Mutex<(SystemInfo, Instant)>> = OnceLock::new();
const SYSTEM_INFO_CACHE_TTL_SECS: u64 = 5;
fn get_cached_system_info() -> SystemInfo {
let cache = SYSTEM_INFO_CACHE.get_or_init(|| {
Mutex::new((get_system_info(), Instant::now()))
});
let mut guard = cache.lock().unwrap();
if guard.1.elapsed().as_secs() >= SYSTEM_INFO_CACHE_TTL_SECS {
guard.0 = get_system_info();
guard.1 = Instant::now();
}
guard.0.clone()
}
#[derive(Clone)]
pub struct ConfigManager {
config: Arc<RwLock<ServerConfig>>,
config_path: PathBuf,
save_sender: tokio::sync::mpsc::UnboundedSender<SaveRequest>,
generation: Arc<AtomicU64>,
saving: Arc<AtomicBool>,
reload_mutex: Arc<tokio::sync::Mutex<()>>,
watcher: Option<Arc<ConfigWatcher>>,
}
impl ConfigManager {
#[must_use]
pub fn new() -> Self {
let config_path = KodegenConfig::user_config_dir()
.map(|dir| dir.join("config.json"))
.unwrap_or_else(|_| PathBuf::from(".kodegen/config.json"));
let (save_sender, save_receiver) = tokio::sync::mpsc::unbounded_channel();
let config = Arc::new(RwLock::new(ServerConfig::default()));
let generation = Arc::new(AtomicU64::new(0));
let saving = Arc::new(AtomicBool::new(false));
let reload_mutex = Arc::new(tokio::sync::Mutex::new(()));
persistence::start_background_saver(
Arc::clone(&config),
config_path.clone(),
save_receiver,
Arc::clone(&generation),
Arc::clone(&saving),
);
Self {
config,
config_path,
save_sender,
generation,
saving,
reload_mutex,
watcher: None,
}
}
pub async fn init(&self) -> Result<(), McpError> {
if let Some(config_dir) = self.config_path.parent() {
tokio::fs::create_dir_all(config_dir).await?;
}
let mut loaded_config = load_with_recovery(&self.config_path).await?;
let env_allowed = load_allowed_dirs_from_env();
let env_denied = load_denied_dirs_from_env();
if !env_allowed.is_empty() {
loaded_config.allowed_directories = env_allowed;
log::info!(
"Loaded {} allowed directories from KODEGEN_ALLOWED_DIRS",
loaded_config.allowed_directories.len()
);
}
if !env_denied.is_empty() {
loaded_config.denied_directories = env_denied;
log::info!(
"Loaded {} denied directories from KODEGEN_DENIED_DIRS",
loaded_config.denied_directories.len()
);
}
let detected_shell = crate::shell_detection::detect_user_shell();
if loaded_config.default_shell != detected_shell {
log::info!(
"🔄 Shell preference changed: {} → {}",
loaded_config.default_shell,
detected_shell
);
loaded_config.default_shell = detected_shell;
} else {
log::debug!("✓ Shell preference unchanged: {}", detected_shell);
}
*self.config.write() = loaded_config;
persistence::save_to_disk(&self.config, &self.config_path).await?;
Ok(())
}
pub async fn reload(&self) -> Result<(), McpError> {
let _guard = self.reload_mutex.lock().await;
log::info!("Reloading configuration from {:?}", self.config_path);
let mut loaded_config = load_with_recovery(&self.config_path).await?;
let env_allowed = load_allowed_dirs_from_env();
let env_denied = load_denied_dirs_from_env();
if !env_allowed.is_empty() {
loaded_config.allowed_directories = env_allowed;
log::info!(
"Preserved {} allowed directories from KODEGEN_ALLOWED_DIRS",
loaded_config.allowed_directories.len()
);
}
if !env_denied.is_empty() {
loaded_config.denied_directories = env_denied;
log::info!(
"Preserved {} denied directories from KODEGEN_DENIED_DIRS",
loaded_config.denied_directories.len()
);
}
let (current_client, client_history) = {
let cfg = self.config.read();
(cfg.current_client.clone(), cfg.client_history.clone())
};
loaded_config.current_client = current_client;
loaded_config.client_history = client_history;
{
let mut config = self.config.write();
*config = loaded_config;
}
let new_gen = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
log::info!("Configuration reloaded successfully (generation {})", new_gen);
Ok(())
}
pub async fn enable_file_watching(&mut self) -> Result<(), McpError> {
if self.watcher.is_some() {
log::warn!("File watching already enabled");
return Ok(());
}
let (reload_tx, mut reload_rx) = tokio::sync::mpsc::unbounded_channel();
let watcher = ConfigWatcher::new(
self.config_path.clone(),
reload_tx,
Arc::clone(&self.saving),
)
.map_err(|e| McpError::Other(anyhow!("Failed to start file watcher: {}", e)))?;
self.watcher = Some(Arc::new(watcher));
let config_manager = self.clone();
tokio::spawn(async move {
while reload_rx.recv().await.is_some() {
if let Err(e) = config_manager.reload().await {
log::error!("Config reload failed: {}", e);
}
}
});
log::info!("Config file watching enabled");
Ok(())
}
#[must_use]
pub fn get_config(&self) -> ServerConfig {
let mut config = self.config.read().clone();
config.system_info = get_cached_system_info();
config
}
#[must_use]
pub fn get_file_read_line_limit(&self) -> usize {
self.config.read().file_read_line_limit
}
#[must_use]
pub fn get_file_write_line_limit(&self) -> usize {
self.config.read().file_write_line_limit
}
#[must_use]
pub fn get_blocked_commands(&self) -> Vec<String> {
self.config.read().blocked_commands.clone()
}
#[must_use]
pub fn get_fuzzy_search_threshold(&self) -> f64 {
self.config.read().fuzzy_search_threshold
}
#[must_use]
pub fn get_http_connection_timeout_secs(&self) -> u64 {
self.config.read().http_connection_timeout_secs
}
#[must_use]
pub fn get_path_validation_timeout_ms(&self) -> u64 {
self.config.read().path_validation_timeout_ms
}
#[must_use]
pub fn get_value(&self, key: &str) -> Option<ConfigValue> {
let config = self.config.read();
match key {
"blocked_commands" => Some(ConfigValue::Array(config.blocked_commands.clone())),
"default_shell" => Some(ConfigValue::String(config.default_shell.clone())),
"allowed_directories" => Some(ConfigValue::Array(config.allowed_directories.clone())),
"denied_directories" => Some(ConfigValue::Array(config.denied_directories.clone())),
"file_read_line_limit" => Some(ConfigValue::Number(
i64::try_from(config.file_read_line_limit).unwrap_or(i64::MAX),
)),
"file_write_line_limit" => Some(ConfigValue::Number(
i64::try_from(config.file_write_line_limit).unwrap_or(i64::MAX),
)),
"fuzzy_search_threshold" => Some(ConfigValue::Number(
(config.fuzzy_search_threshold * 100.0) as i64,
)),
"http_connection_timeout_secs" => Some(ConfigValue::Number(
i64::try_from(config.http_connection_timeout_secs).unwrap_or(i64::MAX),
)),
"path_validation_timeout_ms" => Some(ConfigValue::Number(
i64::try_from(config.path_validation_timeout_ms).unwrap_or(i64::MAX),
)),
_ => None,
}
}
pub async fn set_value(&self, key: &str, value: ConfigValue) -> Result<(), McpError> {
let new_generation = {
let mut config = self.config.write();
match key {
"blocked_commands" => {
config.blocked_commands = value.into_array().map_err(McpError::InvalidArguments)?;
}
"default_shell" => {
let shell_path = value.into_string().map_err(McpError::InvalidArguments)?;
crate::shell_detection::validate_shell_path(&shell_path)
.map_err(McpError::InvalidArguments)?;
config.default_shell = shell_path;
}
"allowed_directories" => {
config.allowed_directories = value.into_array().map_err(McpError::InvalidArguments)?;
}
"denied_directories" => {
config.denied_directories = value.into_array().map_err(McpError::InvalidArguments)?;
}
"file_read_line_limit" => {
let num = value.into_number().map_err(McpError::InvalidArguments)?;
if num <= 0 {
return Err(McpError::InvalidArguments(
"file_read_line_limit must be positive".to_string(),
));
}
config.file_read_line_limit = usize::try_from(num).map_err(|_| {
McpError::InvalidArguments(
"file_read_line_limit value out of range".to_string(),
)
})?;
}
"file_write_line_limit" => {
let num = value.into_number().map_err(McpError::InvalidArguments)?;
if num <= 0 {
return Err(McpError::InvalidArguments(
"file_write_line_limit must be positive".to_string(),
));
}
config.file_write_line_limit = usize::try_from(num).map_err(|_| {
McpError::InvalidArguments(
"file_write_line_limit value out of range".to_string(),
)
})?;
}
"fuzzy_search_threshold" => {
let num = value.into_number().map_err(McpError::InvalidArguments)?;
if !(0..=100).contains(&num) {
return Err(McpError::InvalidArguments(
"fuzzy_search_threshold must be between 0 and 100".to_string(),
));
}
config.fuzzy_search_threshold = (num as f64) / 100.0;
}
"http_connection_timeout_secs" => {
let num = value.into_number().map_err(McpError::InvalidArguments)?;
if num <= 0 {
return Err(McpError::InvalidArguments(
"http_connection_timeout_secs must be positive".to_string(),
));
}
config.http_connection_timeout_secs = u64::try_from(num).map_err(|_| {
McpError::InvalidArguments(
"http_connection_timeout_secs value out of range".to_string(),
)
})?;
}
"path_validation_timeout_ms" => {
let num = value.into_number().map_err(McpError::InvalidArguments)?;
if num <= 0 {
return Err(McpError::InvalidArguments(
"path_validation_timeout_ms must be positive".to_string(),
));
}
if num > 600_000 {
return Err(McpError::InvalidArguments(
"path_validation_timeout_ms cannot exceed 600000ms (10 minutes)".to_string(),
));
}
config.path_validation_timeout_ms = u64::try_from(num).map_err(|_| {
McpError::InvalidArguments(
"path_validation_timeout_ms value out of range".to_string(),
)
})?;
}
_ => {
return Err(McpError::InvalidArguments(format!(
"Unknown config key: {key}"
)));
}
}
config.validate_and_repair()
.map_err(|e| McpError::Other(anyhow::anyhow!("Validation failed: {}", e)))?;
self.generation.fetch_add(1, Ordering::SeqCst) + 1
};
let _ = self.save_sender.send(SaveRequest {
min_generation: new_generation,
});
log::debug!("Config modified, generation {}", new_generation);
Ok(())
}
pub async fn set_client_info(&self, client_info: ClientInfo) {
let new_generation = {
let mut config = self.config.write();
let now = chrono::Utc::now();
let existing = config.client_history.iter_mut().find(|r| {
r.client_info.name == client_info.name
&& r.client_info.version == client_info.version
});
if let Some(record) = existing {
record.last_seen = now;
} else {
if config.client_history.len() >= 100 {
config.client_history.drain(0..50);
log::info!(
"Pruned client_history: removed 50 oldest entries, {} remaining",
config.client_history.len()
);
}
config.client_history.push(crate::system_info::ClientRecord {
client_info: client_info.clone(),
connected_at: now,
last_seen: now,
});
}
config.current_client = Some(client_info);
self.generation.fetch_add(1, Ordering::SeqCst) + 1
};
let _ = self.save_sender.send(SaveRequest {
min_generation: new_generation,
});
}
#[must_use]
pub fn get_client_info(&self) -> Option<ClientInfo> {
self.config.read().current_client.clone()
}
#[must_use]
pub fn get_client_history(&self) -> Vec<crate::system_info::ClientRecord> {
self.config.read().client_history.clone()
}
#[must_use]
pub fn get_save_error_count() -> usize {
persistence::get_save_error_count()
}
}
async fn load_with_recovery(config_path: &PathBuf) -> Result<ServerConfig, McpError> {
match try_load_config(config_path).await {
Ok(config) => {
log::info!("✓ Loaded config from {}", config_path.display());
return Ok(config);
}
Err(e) => {
log::error!(
"✗ Failed to load config from {}: {}",
config_path.display(),
e
);
log::warn!("→ Attempting recovery from backup files...");
}
}
for backup_index in [None, Some(1), Some(2), Some(3)] {
let backup_path_buf = backup_path(config_path, backup_index);
if !backup_path_buf.exists() {
continue;
}
log::info!("→ Trying backup: {}", backup_path_buf.display());
match try_load_config(&backup_path_buf).await {
Ok(config) => {
log::warn!(
"✓ Recovered config from backup: {}",
backup_path_buf.display()
);
log::warn!(
"→ Restoring backup to main config file: {}",
config_path.display()
);
tokio::fs::copy(&backup_path_buf, config_path).await?;
return Ok(config);
}
Err(e) => {
log::error!(
"✗ Backup {} also corrupted: {}",
backup_path_buf.display(),
e
);
}
}
}
log::error!("❌ All config backups corrupted or missing!");
log::error!("→ Main config: FAILED");
log::error!("→ .backup: FAILED");
log::error!("→ .backup.1: FAILED");
log::error!("→ .backup.2: FAILED");
log::error!("→ .backup.3: FAILED");
log::warn!("🔄 Creating fresh config with defaults");
log::warn!("⚠️ All previous settings and client history lost");
Ok(ServerConfig::default())
}
async fn try_load_config(path: &PathBuf) -> Result<ServerConfig, anyhow::Error> {
use anyhow::Context;
let content = tokio::fs::read_to_string(path)
.await
.context("Failed to read config file")?;
let config = crate::migration::load_with_migration(&content)
.context("Failed to load config with migration")?;
Ok(config)
}
impl Default for ConfigManager {
fn default() -> Self {
Self::new()
}
}