vtcode_core/utils/
dot_config.rs

1//! Dot folder configuration and cache management
2
3use crate::config::constants::defaults;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fmt;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// VTCode configuration stored in ~/.vtcode/
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct DotConfig {
13    pub version: String,
14    pub last_updated: u64,
15    pub preferences: UserPreferences,
16    pub providers: ProviderConfigs,
17    pub cache: CacheConfig,
18    pub ui: UiConfig,
19    #[serde(default)]
20    pub workspace_trust: WorkspaceTrustStore,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct UserPreferences {
25    pub default_model: String,
26    pub default_provider: String,
27    pub max_tokens: Option<u32>,
28    pub temperature: Option<f32>,
29    pub auto_save: bool,
30    pub theme: String,
31    pub keybindings: HashMap<String, String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct ProviderConfigs {
36    pub openai: Option<ProviderConfig>,
37    pub anthropic: Option<ProviderConfig>,
38    pub gemini: Option<ProviderConfig>,
39    pub deepseek: Option<ProviderConfig>,
40    pub openrouter: Option<ProviderConfig>,
41    pub xai: Option<ProviderConfig>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct WorkspaceTrustStore {
46    #[serde(default)]
47    pub entries: HashMap<String, WorkspaceTrustRecord>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct WorkspaceTrustRecord {
52    pub level: WorkspaceTrustLevel,
53    pub trusted_at: u64,
54}
55
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "snake_case")]
58pub enum WorkspaceTrustLevel {
59    ToolsPolicy,
60    FullAuto,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct ProviderConfig {
65    pub api_key: Option<String>,
66    pub base_url: Option<String>,
67    pub model: Option<String>,
68    pub enabled: bool,
69    pub priority: i32, // Higher priority = preferred
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CacheConfig {
74    pub enabled: bool,
75    pub max_size_mb: u64,
76    pub ttl_days: u64,
77    pub prompt_cache_enabled: bool,
78    pub context_cache_enabled: bool,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct UiConfig {
83    pub show_timestamps: bool,
84    pub max_output_lines: usize,
85    pub syntax_highlighting: bool,
86    pub auto_complete: bool,
87    pub history_size: usize,
88}
89
90impl Default for DotConfig {
91    fn default() -> Self {
92        Self {
93            version: env!("CARGO_PKG_VERSION").to_string(),
94            last_updated: std::time::SystemTime::now()
95                .duration_since(std::time::UNIX_EPOCH)
96                .unwrap()
97                .as_secs(),
98            preferences: UserPreferences::default(),
99            providers: ProviderConfigs::default(),
100            cache: CacheConfig::default(),
101            ui: UiConfig::default(),
102            workspace_trust: WorkspaceTrustStore::default(),
103        }
104    }
105}
106
107impl Default for UserPreferences {
108    fn default() -> Self {
109        Self {
110            default_model: defaults::DEFAULT_MODEL.to_string(),
111            default_provider: defaults::DEFAULT_PROVIDER.to_string(),
112            max_tokens: Some(4096),
113            temperature: Some(0.7),
114            auto_save: true,
115            theme: defaults::DEFAULT_THEME.to_string(),
116            keybindings: HashMap::new(),
117        }
118    }
119}
120
121impl Default for WorkspaceTrustLevel {
122    fn default() -> Self {
123        Self::ToolsPolicy
124    }
125}
126
127impl fmt::Display for WorkspaceTrustLevel {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            WorkspaceTrustLevel::ToolsPolicy => write!(f, "tools policy"),
131            WorkspaceTrustLevel::FullAuto => write!(f, "full auto"),
132        }
133    }
134}
135
136impl Default for CacheConfig {
137    fn default() -> Self {
138        Self {
139            enabled: true,
140            max_size_mb: 100,
141            ttl_days: 30,
142            prompt_cache_enabled: true,
143            context_cache_enabled: true,
144        }
145    }
146}
147
148impl Default for UiConfig {
149    fn default() -> Self {
150        Self {
151            show_timestamps: true,
152            max_output_lines: 1000,
153            syntax_highlighting: true,
154            auto_complete: true,
155            history_size: 1000,
156        }
157    }
158}
159
160/// Dot folder manager for VTCode configuration and cache
161pub struct DotManager {
162    config_dir: PathBuf,
163    cache_dir: PathBuf,
164    config_file: PathBuf,
165}
166
167impl DotManager {
168    pub fn new() -> Result<Self, DotError> {
169        let home_dir = dirs::home_dir().ok_or(DotError::HomeDirNotFound)?;
170
171        let config_dir = home_dir.join(".vtcode");
172        let cache_dir = config_dir.join("cache");
173        let config_file = config_dir.join("config.toml");
174
175        Ok(Self {
176            config_dir,
177            cache_dir,
178            config_file,
179        })
180    }
181
182    /// Initialize the dot folder structure
183    pub fn initialize(&self) -> Result<(), DotError> {
184        // Create directories
185        fs::create_dir_all(&self.config_dir).map_err(DotError::Io)?;
186        fs::create_dir_all(&self.cache_dir).map_err(DotError::Io)?;
187
188        // Create subdirectories
189        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)).map_err(DotError::Io)?;
200        }
201
202        // Create default config if it doesn't exist
203        if !self.config_file.exists() {
204            let default_config = DotConfig::default();
205            self.save_config(&default_config)?;
206        }
207
208        Ok(())
209    }
210
211    /// Load configuration from disk
212    pub fn load_config(&self) -> Result<DotConfig, DotError> {
213        if !self.config_file.exists() {
214            return Ok(DotConfig::default());
215        }
216
217        let content = fs::read_to_string(&self.config_file).map_err(DotError::Io)?;
218
219        toml::from_str(&content).map_err(DotError::TomlDe)
220    }
221
222    /// Save configuration to disk
223    pub fn save_config(&self, config: &DotConfig) -> Result<(), DotError> {
224        let content = toml::to_string_pretty(config).map_err(DotError::Toml)?;
225
226        fs::write(&self.config_file, content).map_err(DotError::Io)?;
227
228        Ok(())
229    }
230
231    /// Update configuration with new values
232    pub fn update_config<F>(&self, updater: F) -> Result<(), DotError>
233    where
234        F: FnOnce(&mut DotConfig),
235    {
236        let mut config = self.load_config()?;
237        updater(&mut config);
238        config.last_updated = std::time::SystemTime::now()
239            .duration_since(std::time::UNIX_EPOCH)
240            .unwrap()
241            .as_secs();
242        self.save_config(&config)
243    }
244
245    /// Get cache directory for a specific type
246    pub fn cache_dir(&self, cache_type: &str) -> PathBuf {
247        self.cache_dir.join(cache_type)
248    }
249
250    /// Get logs directory
251    pub fn logs_dir(&self) -> PathBuf {
252        self.config_dir.join("logs")
253    }
254
255    /// Get sessions directory
256    pub fn sessions_dir(&self) -> PathBuf {
257        self.config_dir.join("sessions")
258    }
259
260    /// Get backups directory
261    pub fn backups_dir(&self) -> PathBuf {
262        self.config_dir.join("backups")
263    }
264
265    /// Clean up old cache files
266    pub fn cleanup_cache(&self) -> Result<CacheCleanupStats, DotError> {
267        let config = self.load_config()?;
268        let max_age = std::time::Duration::from_secs(config.cache.ttl_days * 24 * 60 * 60);
269        let now = std::time::SystemTime::now();
270
271        let mut stats = CacheCleanupStats::default();
272
273        // Clean prompt cache
274        if config.cache.prompt_cache_enabled {
275            stats.prompts_cleaned =
276                self.cleanup_directory(&self.cache_dir("prompts"), max_age, now)?;
277        }
278
279        // Clean context cache
280        if config.cache.context_cache_enabled {
281            stats.context_cleaned =
282                self.cleanup_directory(&self.cache_dir("context"), max_age, now)?;
283        }
284
285        // Clean model cache
286        stats.models_cleaned = self.cleanup_directory(&self.cache_dir("models"), max_age, now)?;
287
288        Ok(stats)
289    }
290
291    /// Clean up files in a directory older than max_age
292    fn cleanup_directory(
293        &self,
294        dir: &Path,
295        max_age: std::time::Duration,
296        now: std::time::SystemTime,
297    ) -> Result<u64, DotError> {
298        if !dir.exists() {
299            return Ok(0);
300        }
301
302        let mut cleaned = 0u64;
303
304        for entry in fs::read_dir(dir).map_err(DotError::Io)? {
305            let entry = entry.map_err(DotError::Io)?;
306            let path = entry.path();
307
308            if let Ok(metadata) = entry.metadata()
309                && let Ok(modified) = metadata.modified()
310                && let Ok(age) = now.duration_since(modified)
311                && age > max_age
312            {
313                if path.is_file() {
314                    fs::remove_file(&path).map_err(DotError::Io)?;
315                    cleaned += 1;
316                } else if path.is_dir() {
317                    fs::remove_dir_all(&path).map_err(DotError::Io)?;
318                    cleaned += 1;
319                }
320            }
321        }
322
323        Ok(cleaned)
324    }
325
326    /// Get disk usage statistics
327    pub fn disk_usage(&self) -> Result<DiskUsageStats, DotError> {
328        let mut stats = DiskUsageStats::default();
329
330        stats.config_size = self.calculate_dir_size(&self.config_dir)?;
331        stats.cache_size = self.calculate_dir_size(&self.cache_dir)?;
332        stats.logs_size = self.calculate_dir_size(&self.logs_dir())?;
333        stats.sessions_size = self.calculate_dir_size(&self.sessions_dir())?;
334        stats.backups_size = self.calculate_dir_size(&self.backups_dir())?;
335
336        stats.total_size = stats.config_size
337            + stats.cache_size
338            + stats.logs_size
339            + stats.sessions_size
340            + stats.backups_size;
341
342        Ok(stats)
343    }
344
345    /// Calculate directory size recursively
346    fn calculate_dir_size(&self, dir: &Path) -> Result<u64, DotError> {
347        if !dir.exists() {
348            return Ok(0);
349        }
350
351        let mut size = 0u64;
352
353        fn calculate_recursive(path: &Path, current_size: &mut u64) -> Result<(), DotError> {
354            if path.is_file() {
355                if let Ok(metadata) = path.metadata() {
356                    *current_size += metadata.len();
357                }
358            } else if path.is_dir() {
359                for entry in fs::read_dir(path).map_err(DotError::Io)? {
360                    let entry = entry.map_err(DotError::Io)?;
361                    calculate_recursive(&entry.path(), current_size)?;
362                }
363            }
364            Ok(())
365        }
366
367        calculate_recursive(dir, &mut size)?;
368        Ok(size)
369    }
370
371    /// Backup current configuration
372    pub fn backup_config(&self) -> Result<PathBuf, DotError> {
373        let timestamp = std::time::SystemTime::now()
374            .duration_since(std::time::UNIX_EPOCH)
375            .unwrap()
376            .as_secs();
377
378        let backup_name = format!("config_backup_{}.toml", timestamp);
379        let backup_path = self.backups_dir().join(backup_name);
380
381        if self.config_file.exists() {
382            fs::copy(&self.config_file, &backup_path).map_err(DotError::Io)?;
383        }
384
385        Ok(backup_path)
386    }
387
388    /// List available backups
389    pub fn list_backups(&self) -> Result<Vec<PathBuf>, DotError> {
390        let backups_dir = self.backups_dir();
391        if !backups_dir.exists() {
392            return Ok(vec![]);
393        }
394
395        let mut backups = vec![];
396
397        for entry in fs::read_dir(backups_dir).map_err(DotError::Io)? {
398            let entry = entry.map_err(DotError::Io)?;
399            if entry.path().extension().and_then(|e| e.to_str()) == Some("toml") {
400                backups.push(entry.path());
401            }
402        }
403
404        // Sort by modification time (newest first)
405        backups.sort_by(|a, b| {
406            let a_time = a.metadata().and_then(|m| m.modified()).ok();
407            let b_time = b.metadata().and_then(|m| m.modified()).ok();
408            b_time.cmp(&a_time)
409        });
410
411        Ok(backups)
412    }
413
414    /// Restore configuration from backup
415    pub fn restore_backup(&self, backup_path: &Path) -> Result<(), DotError> {
416        if !backup_path.exists() {
417            return Err(DotError::BackupNotFound(backup_path.to_path_buf()));
418        }
419
420        fs::copy(backup_path, &self.config_file).map_err(DotError::Io)?;
421
422        Ok(())
423    }
424}
425
426#[derive(Debug, Default)]
427pub struct CacheCleanupStats {
428    pub prompts_cleaned: u64,
429    pub context_cleaned: u64,
430    pub models_cleaned: u64,
431}
432
433#[derive(Debug, Default)]
434pub struct DiskUsageStats {
435    pub config_size: u64,
436    pub cache_size: u64,
437    pub logs_size: u64,
438    pub sessions_size: u64,
439    pub backups_size: u64,
440    pub total_size: u64,
441}
442
443/// Dot folder management errors
444#[derive(Debug, thiserror::Error)]
445pub enum DotError {
446    #[error("Home directory not found")]
447    HomeDirNotFound,
448
449    #[error("IO error: {0}")]
450    Io(#[from] std::io::Error),
451
452    #[error("TOML serialization error: {0}")]
453    Toml(#[from] toml::ser::Error),
454
455    #[error("TOML deserialization error: {0}")]
456    TomlDe(#[from] toml::de::Error),
457
458    #[error("Backup not found: {0}")]
459    BackupNotFound(PathBuf),
460}
461
462use std::sync::{LazyLock, Mutex};
463
464/// Global dot manager instance
465static DOT_MANAGER: LazyLock<Mutex<DotManager>> =
466    LazyLock::new(|| Mutex::new(DotManager::new().unwrap()));
467
468/// Get global dot manager instance
469pub fn get_dot_manager() -> &'static Mutex<DotManager> {
470    &DOT_MANAGER
471}
472
473/// Initialize dot folder (should be called at startup)
474pub fn initialize_dot_folder() -> Result<(), DotError> {
475    let manager = get_dot_manager().lock().unwrap();
476    manager.initialize()
477}
478
479/// Load user configuration
480pub fn load_user_config() -> Result<DotConfig, DotError> {
481    let manager = get_dot_manager().lock().unwrap();
482    manager.load_config()
483}
484
485/// Save user configuration
486pub fn save_user_config(config: &DotConfig) -> Result<(), DotError> {
487    let manager = get_dot_manager().lock().unwrap();
488    manager.save_config(config)
489}
490
491/// Persist the preferred UI theme in the user's dot configuration.
492pub fn update_theme_preference(theme: &str) -> Result<(), DotError> {
493    let manager = get_dot_manager().lock().unwrap();
494    manager.update_config(|cfg| cfg.preferences.theme = theme.to_string())
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use tempfile::TempDir;
501
502    #[test]
503    fn test_dot_manager_initialization() {
504        let temp_dir = TempDir::new().unwrap();
505        let config_dir = temp_dir.path().join(".vtcode");
506
507        // Test directory creation
508        assert!(!config_dir.exists());
509
510        let manager = DotManager {
511            config_dir: config_dir.clone(),
512            cache_dir: config_dir.join("cache"),
513            config_file: config_dir.join("config.toml"),
514        };
515
516        manager.initialize().unwrap();
517        assert!(config_dir.exists());
518        assert!(config_dir.join("cache").exists());
519        assert!(config_dir.join("logs").exists());
520    }
521
522    #[test]
523    fn test_config_save_load() {
524        let temp_dir = TempDir::new().unwrap();
525        let config_dir = temp_dir.path().join(".vtcode");
526
527        let manager = DotManager {
528            config_dir: config_dir.clone(),
529            cache_dir: config_dir.join("cache"),
530            config_file: config_dir.join("config.toml"),
531        };
532
533        manager.initialize().unwrap();
534
535        let mut config = DotConfig::default();
536        config.preferences.default_model = "test-model".to_string();
537
538        manager.save_config(&config).unwrap();
539        let loaded_config = manager.load_config().unwrap();
540
541        assert_eq!(loaded_config.preferences.default_model, "test-model");
542    }
543}