1pub use crate::config::WorkspaceTrustLevel;
4use crate::config::constants::defaults;
5use crate::config::defaults::get_config_dir;
6use crate::utils::path::canonicalize_workspace;
7use hashbrown::HashMap;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::sync::{Mutex, OnceLock};
11use tokio::fs;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct DotConfig {
16 pub version: String,
17 pub last_updated: u64,
18 pub preferences: UserPreferences,
19 pub providers: ProviderConfigs,
20 pub cache: CacheConfig,
21 pub ui: UiConfig,
22 #[serde(default)]
23 pub workspace_trust: WorkspaceTrustStore,
24 #[serde(default)]
25 pub dependency_notices: DependencyNoticeStore,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct UserPreferences {
30 pub default_model: String,
31 pub default_provider: String,
32 pub max_tokens: Option<u32>,
33 pub temperature: Option<f32>,
34 pub auto_save: bool,
35 pub theme: String,
36 pub keybindings: HashMap<String, String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct ProviderConfigs {
41 pub openai: Option<ProviderConfig>,
42 pub anthropic: Option<ProviderConfig>,
43 pub gemini: Option<ProviderConfig>,
44 pub deepseek: Option<ProviderConfig>,
45 pub openrouter: Option<ProviderConfig>,
46 pub ollama: Option<ProviderConfig>,
47 pub lmstudio: Option<ProviderConfig>,
48 pub llamacpp: Option<ProviderConfig>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub minimax: Option<ProviderConfig>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub stepfun: Option<ProviderConfig>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub evolink: Option<ProviderConfig>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct WorkspaceTrustStore {
59 #[serde(default)]
60 pub entries: HashMap<String, WorkspaceTrustRecord>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct WorkspaceTrustRecord {
65 pub level: WorkspaceTrustLevel,
66 pub trusted_at: u64,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70pub struct DependencyNoticeStore {
71 #[serde(default)]
72 pub ripgrep_missing_notice_shown: bool,
73 #[serde(default)]
74 pub ast_grep_missing_notice_shown: bool,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct ProviderConfig {
79 pub api_key: Option<String>,
80 pub base_url: Option<String>,
81 pub model: Option<String>,
82 pub enabled: bool,
83 pub priority: i32, }
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct CacheConfig {
88 pub enabled: bool,
89 pub max_size_mb: u64,
90 pub ttl_days: u64,
91 pub prompt_cache_enabled: bool,
92 pub context_cache_enabled: bool,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct UiConfig {
97 pub show_timestamps: bool,
98 pub max_output_lines: usize,
99 pub syntax_highlighting: bool,
100 pub auto_complete: bool,
101 pub history_size: usize,
102}
103
104impl Default for DotConfig {
105 fn default() -> Self {
106 Self {
107 version: env!("CARGO_PKG_VERSION").into(),
108 last_updated: unix_timestamp_secs().unwrap_or(0),
109 preferences: UserPreferences::default(),
110 providers: ProviderConfigs::default(),
111 cache: CacheConfig::default(),
112 ui: UiConfig::default(),
113 workspace_trust: WorkspaceTrustStore::default(),
114 dependency_notices: DependencyNoticeStore::default(),
115 }
116 }
117}
118
119impl Default for UserPreferences {
120 fn default() -> Self {
121 Self {
122 default_model: defaults::DEFAULT_MODEL.into(),
123 default_provider: defaults::DEFAULT_PROVIDER.into(),
124 max_tokens: Some(4096),
125 temperature: Some(0.7),
126 auto_save: true,
127 theme: defaults::DEFAULT_THEME.into(),
128 keybindings: HashMap::new(),
129 }
130 }
131}
132
133impl Default for CacheConfig {
134 fn default() -> Self {
135 Self {
136 enabled: true,
137 max_size_mb: 100,
138 ttl_days: 30,
139 prompt_cache_enabled: true,
140 context_cache_enabled: true,
141 }
142 }
143}
144
145impl Default for UiConfig {
146 fn default() -> Self {
147 Self {
148 show_timestamps: true,
149 max_output_lines: 1000,
150 syntax_highlighting: true,
151 auto_complete: true,
152 history_size: 1000,
153 }
154 }
155}
156
157#[derive(Clone)]
159pub struct DotManager {
160 config_dir: PathBuf,
161 cache_dir: PathBuf,
162 config_file: PathBuf,
163}
164
165impl DotManager {
166 pub fn new() -> Result<Self, DotError> {
167 let config_dir = get_config_dir().ok_or(DotError::HomeDirNotFound)?;
168 let cache_dir = config_dir.join("cache");
169 let config_file = config_dir.join("config.toml");
170
171 Ok(Self {
172 config_dir,
173 cache_dir,
174 config_file,
175 })
176 }
177
178 pub async fn initialize(&self) -> Result<(), DotError> {
180 fs::create_dir_all(&self.config_dir)
182 .await
183 .map_err(DotError::Io)?;
184 fs::create_dir_all(&self.cache_dir)
185 .await
186 .map_err(DotError::Io)?;
187
188 let subdirs = [
190 "cache/prompts",
191 "cache/context",
192 "cache/models",
193 "logs",
194 "sessions",
195 "backups",
196 ];
197
198 for subdir in &subdirs {
199 fs::create_dir_all(self.config_dir.join(subdir))
200 .await
201 .map_err(DotError::Io)?;
202 }
203
204 if !fs::try_exists(&self.config_file).await.unwrap_or(false) {
206 let default_config = DotConfig::default();
207 self.save_config(&default_config).await?;
208 }
209
210 Ok(())
211 }
212
213 pub async fn load_config(&self) -> Result<DotConfig, DotError> {
215 if !fs::try_exists(&self.config_file).await.unwrap_or(false) {
216 return Ok(DotConfig::default());
217 }
218
219 let content = fs::read_to_string(&self.config_file)
220 .await
221 .map_err(DotError::Io)?;
222
223 toml::from_str(&content).map_err(DotError::TomlDe)
224 }
225
226 pub async fn save_config(&self, config: &DotConfig) -> Result<(), DotError> {
228 let content = toml::to_string_pretty(config).map_err(DotError::Toml)?;
229
230 fs::write(&self.config_file, content)
231 .await
232 .map_err(DotError::Io)?;
233
234 Ok(())
235 }
236
237 pub async fn update_config<F>(&self, updater: F) -> Result<(), DotError>
239 where
240 F: FnOnce(&mut DotConfig),
241 {
242 let mut config = self.load_config().await?;
243 updater(&mut config);
244 config.last_updated = unix_timestamp_secs()?;
245 self.save_config(&config).await
246 }
247
248 pub async fn workspace_trust_level(
250 &self,
251 workspace: &Path,
252 ) -> Result<Option<WorkspaceTrustLevel>, DotError> {
253 let workspace_key = workspace_trust_key(workspace);
254 let config = self.load_config().await?;
255
256 Ok(config
257 .workspace_trust
258 .entries
259 .get(&workspace_key)
260 .map(|record| record.level))
261 }
262
263 pub async fn update_workspace_trust(
265 &self,
266 workspace: &Path,
267 level: WorkspaceTrustLevel,
268 ) -> Result<(), DotError> {
269 let workspace_key = workspace_trust_key(workspace);
270 let trusted_at = unix_timestamp_secs()?;
271
272 self.update_config(|cfg| {
273 cfg.workspace_trust
274 .entries
275 .insert(workspace_key, WorkspaceTrustRecord { level, trusted_at });
276 })
277 .await
278 }
279
280 pub fn cache_dir(&self, cache_type: &str) -> PathBuf {
282 self.cache_dir.join(cache_type)
283 }
284
285 pub fn logs_dir(&self) -> PathBuf {
287 self.config_dir.join("logs")
288 }
289
290 pub fn sessions_dir(&self) -> PathBuf {
292 self.config_dir.join("sessions")
293 }
294
295 pub fn backups_dir(&self) -> PathBuf {
297 self.config_dir.join("backups")
298 }
299
300 pub async fn cleanup_cache(&self) -> Result<CacheCleanupStats, DotError> {
302 let config = self.load_config().await?;
303 let max_age = std::time::Duration::from_secs(config.cache.ttl_days * 24 * 60 * 60);
304 let now = std::time::SystemTime::now();
305
306 let mut stats = CacheCleanupStats::default();
307
308 if config.cache.prompt_cache_enabled {
310 stats.prompts_cleaned = self
311 .cleanup_directory(&self.cache_dir("prompts"), max_age, now)
312 .await?;
313 }
314
315 if config.cache.context_cache_enabled {
317 stats.context_cleaned = self
318 .cleanup_directory(&self.cache_dir("context"), max_age, now)
319 .await?;
320 }
321
322 stats.models_cleaned = self
324 .cleanup_directory(&self.cache_dir("models"), max_age, now)
325 .await?;
326
327 Ok(stats)
328 }
329
330 async fn cleanup_directory(
332 &self,
333 dir: &Path,
334 max_age: std::time::Duration,
335 now: std::time::SystemTime,
336 ) -> Result<u64, DotError> {
337 if !fs::try_exists(dir).await.unwrap_or(false) {
338 return Ok(0);
339 }
340
341 let mut cleaned = 0u64;
342 let mut entries = fs::read_dir(dir).await.map_err(DotError::Io)?;
343
344 while let Ok(Some(entry)) = entries.next_entry().await {
345 let path = entry.path();
346
347 if let Ok(metadata) = entry.metadata().await
348 && let Ok(modified) = metadata.modified()
349 && let Ok(age) = now.duration_since(modified)
350 && age > max_age
351 {
352 if path.is_file() {
353 fs::remove_file(&path).await.map_err(DotError::Io)?;
354 cleaned += 1;
355 } else if path.is_dir() {
356 fs::remove_dir_all(&path).await.map_err(DotError::Io)?;
357 cleaned += 1;
358 }
359 }
360 }
361
362 Ok(cleaned)
363 }
364
365 pub async fn disk_usage(&self) -> Result<DiskUsageStats, DotError> {
367 let mut stats = DiskUsageStats::default();
368
369 stats.config_size = self.calculate_dir_size(&self.config_dir).await?;
370 stats.cache_size = self.calculate_dir_size(&self.cache_dir).await?;
371 stats.logs_size = self.calculate_dir_size(&self.logs_dir()).await?;
372 stats.sessions_size = self.calculate_dir_size(&self.sessions_dir()).await?;
373 stats.backups_size = self.calculate_dir_size(&self.backups_dir()).await?;
374
375 stats.total_size = stats.config_size
376 + stats.cache_size
377 + stats.logs_size
378 + stats.sessions_size
379 + stats.backups_size;
380
381 Ok(stats)
382 }
383
384 async fn calculate_dir_size(&self, dir: &Path) -> Result<u64, DotError> {
386 if !fs::try_exists(dir).await.unwrap_or(false) {
387 return Ok(0);
388 }
389
390 let mut size = 0u64;
391
392 fn calculate_recursive<'a>(
393 path: &'a Path,
394 current_size: &'a mut u64,
395 ) -> std::pin::Pin<Box<dyn Future<Output = Result<(), DotError>> + Send + 'a>> {
396 Box::pin(async move {
397 let metadata = fs::metadata(path).await.map_err(DotError::Io)?;
398 if metadata.is_file() {
399 *current_size += metadata.len();
400 } else if metadata.is_dir() {
401 let mut entries = fs::read_dir(path).await.map_err(DotError::Io)?;
402 while let Ok(Some(entry)) = entries.next_entry().await {
403 calculate_recursive(&entry.path(), current_size).await?;
404 }
405 }
406 Ok(())
407 })
408 }
409
410 calculate_recursive(dir, &mut size).await?;
411 Ok(size)
412 }
413
414 pub async fn backup_config(&self) -> Result<PathBuf, DotError> {
416 let timestamp = unix_timestamp_secs()?;
417
418 let backup_name = format!("config_backup_{}.toml", timestamp);
419 let backup_path = self.backups_dir().join(backup_name);
420
421 if fs::try_exists(&self.config_file).await.unwrap_or(false) {
422 fs::copy(&self.config_file, &backup_path)
423 .await
424 .map_err(DotError::Io)?;
425 }
426
427 Ok(backup_path)
428 }
429
430 pub async fn list_backups(&self) -> Result<Vec<PathBuf>, DotError> {
432 let backups_dir = self.backups_dir();
433 if !fs::try_exists(&backups_dir).await.unwrap_or(false) {
434 return Ok(vec![]);
435 }
436
437 let mut backups = vec![];
438 let mut entries = fs::read_dir(backups_dir).await.map_err(DotError::Io)?;
439
440 while let Ok(Some(entry)) = entries.next_entry().await {
441 if entry.path().extension().and_then(|e| e.to_str()) == Some("toml") {
442 backups.push(entry.path());
443 }
444 }
445
446 let mut backup_times = Vec::new();
449 for backup in &backups {
450 let time = fs::metadata(backup)
451 .await
452 .ok()
453 .and_then(|m| m.modified().ok());
454 backup_times.push((backup.clone(), time));
455 }
456 backup_times.sort_by(|a, b| b.1.cmp(&a.1));
457
458 Ok(backup_times.into_iter().map(|(path, _)| path).collect())
459 }
460
461 pub async fn restore_backup(&self, backup_path: &Path) -> Result<(), DotError> {
463 if !fs::try_exists(backup_path).await.unwrap_or(false) {
464 return Err(DotError::BackupNotFound(backup_path.to_path_buf()));
465 }
466
467 fs::copy(backup_path, &self.config_file)
468 .await
469 .map_err(DotError::Io)?;
470
471 Ok(())
472 }
473}
474
475#[derive(Debug, Default)]
476pub struct CacheCleanupStats {
477 pub prompts_cleaned: u64,
478 pub context_cleaned: u64,
479 pub models_cleaned: u64,
480}
481
482#[derive(Debug, Default)]
483pub struct DiskUsageStats {
484 pub config_size: u64,
485 pub cache_size: u64,
486 pub logs_size: u64,
487 pub sessions_size: u64,
488 pub backups_size: u64,
489 pub total_size: u64,
490}
491
492#[derive(Debug, thiserror::Error)]
494pub enum DotError {
495 #[error("Home directory not found")]
496 HomeDirNotFound,
497
498 #[error("System time error: {0}")]
499 SystemTime(#[from] std::time::SystemTimeError),
500
501 #[error("IO error: {0}")]
502 Io(#[from] std::io::Error),
503
504 #[error("TOML serialization error: {0}")]
505 Toml(#[from] toml::ser::Error),
506
507 #[error("TOML deserialization error: {0}")]
508 TomlDe(#[from] toml::de::Error),
509
510 #[error("Backup not found: {0}")]
511 BackupNotFound(PathBuf),
512
513 #[error("Dot manager lock poisoned: {0}")]
514 LockPoisoned(String),
515}
516
517fn unix_timestamp_secs() -> Result<u64, DotError> {
518 Ok(std::time::SystemTime::now()
519 .duration_since(std::time::UNIX_EPOCH)?
520 .as_secs())
521}
522
523fn workspace_trust_key(workspace: &Path) -> String {
524 canonicalize_workspace(workspace)
525 .to_string_lossy()
526 .into_owned()
527}
528
529static DOT_MANAGER: OnceLock<Mutex<DotManager>> = OnceLock::new();
531
532pub fn get_dot_manager() -> Result<&'static Mutex<DotManager>, DotError> {
534 if let Some(manager) = DOT_MANAGER.get() {
535 return Ok(manager);
536 }
537
538 let manager = DotManager::new()?;
539 Ok(DOT_MANAGER.get_or_init(|| Mutex::new(manager)))
540}
541
542fn clone_manager() -> Result<DotManager, DotError> {
543 let manager = get_dot_manager()?;
544 let guard = manager
545 .lock()
546 .map_err(|err| DotError::LockPoisoned(err.to_string()))?;
547 Ok(guard.clone())
548}
549
550pub async fn initialize_dot_folder() -> Result<(), DotError> {
552 let manager = clone_manager()?;
553 manager.initialize().await
554}
555
556pub async fn load_user_config() -> Result<DotConfig, DotError> {
558 let manager = clone_manager()?;
559 manager.load_config().await
560}
561
562pub async fn save_user_config(config: &DotConfig) -> Result<(), DotError> {
564 let manager = clone_manager()?;
565 manager.save_config(config).await
566}
567
568pub async fn load_workspace_trust_level(
570 workspace: &Path,
571) -> Result<Option<WorkspaceTrustLevel>, DotError> {
572 let manager = clone_manager()?;
573 manager.workspace_trust_level(workspace).await
574}
575
576pub async fn update_workspace_trust(
578 workspace: &Path,
579 level: WorkspaceTrustLevel,
580) -> Result<(), DotError> {
581 let manager = clone_manager()?;
582 manager.update_workspace_trust(workspace, level).await
583}
584
585pub async fn update_theme_preference(theme: &str) -> Result<(), DotError> {
587 let manager = clone_manager()?;
588 manager
589 .update_config(|cfg| cfg.preferences.theme = theme.to_string())
590 .await
591}
592
593pub async fn update_model_preference(provider: &str, model: &str) -> Result<(), DotError> {
595 let manager = clone_manager()?;
596 manager
597 .update_config(|cfg| {
598 cfg.preferences.default_provider = provider.to_string();
599 cfg.preferences.default_model = model.to_string();
600 })
601 .await
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use tempfile::TempDir;
608
609 #[tokio::test]
610 async fn test_dot_manager_initialization() {
611 let temp_dir = TempDir::new().unwrap();
612 let config_dir = temp_dir.path().join(".vtcode");
613
614 assert!(!config_dir.exists());
616
617 let manager = DotManager {
618 config_dir: config_dir.clone(),
619 cache_dir: config_dir.join("cache"),
620 config_file: config_dir.join("config.toml"),
621 };
622
623 manager.initialize().await.unwrap();
624 assert!(config_dir.exists());
625 assert!(config_dir.join("cache").exists());
626 assert!(config_dir.join("logs").exists());
627 }
628
629 #[tokio::test]
630 async fn test_config_save_load() {
631 let temp_dir = TempDir::new().unwrap();
632 let config_dir = temp_dir.path().join(".vtcode");
633
634 let manager = DotManager {
635 config_dir: config_dir.clone(),
636 cache_dir: config_dir.join("cache"),
637 config_file: config_dir.join("config.toml"),
638 };
639
640 manager.initialize().await.unwrap();
641
642 let mut config = DotConfig::default();
643 config.preferences.default_model = "test-model".to_owned();
644
645 manager.save_config(&config).await.unwrap();
646 let loaded_config = manager.load_config().await.unwrap();
647
648 assert_eq!(loaded_config.preferences.default_model, "test-model");
649 }
650}