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