Skip to main content

vtcode_core/utils/
dot_config.rs

1//! Dot folder configuration and cache management
2
3pub 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/// VT Code configuration stored in ~/.vtcode/
14#[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, // Higher priority = preferred
84}
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/// Dot folder manager for VT Code configuration and cache
158#[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    /// Initialize the dot folder structure
179    pub async fn initialize(&self) -> Result<(), DotError> {
180        // Create directories
181        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        // 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))
200                .await
201                .map_err(DotError::Io)?;
202        }
203
204        // Create default config if it doesn't exist
205        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    /// Load configuration from disk
214    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    /// Save configuration to disk
227    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    /// Update configuration with new values
238    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    /// Load the trust level recorded for a workspace, if any.
249    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    /// Persist a workspace trust level in the dot configuration.
264    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    /// Get cache directory for a specific type
281    pub fn cache_dir(&self, cache_type: &str) -> PathBuf {
282        self.cache_dir.join(cache_type)
283    }
284
285    /// Get logs directory
286    pub fn logs_dir(&self) -> PathBuf {
287        self.config_dir.join("logs")
288    }
289
290    /// Get sessions directory
291    pub fn sessions_dir(&self) -> PathBuf {
292        self.config_dir.join("sessions")
293    }
294
295    /// Get backups directory
296    pub fn backups_dir(&self) -> PathBuf {
297        self.config_dir.join("backups")
298    }
299
300    /// Clean up old cache files
301    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        // Clean prompt cache
309        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        // Clean context cache
316        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        // Clean model cache
323        stats.models_cleaned = self
324            .cleanup_directory(&self.cache_dir("models"), max_age, now)
325            .await?;
326
327        Ok(stats)
328    }
329
330    /// Clean up files in a directory older than max_age
331    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    /// Get disk usage statistics
366    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    /// Calculate directory size recursively
385    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    /// Backup current configuration
415    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    /// List available backups
431    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        // Sort by modification time (newest first)
447        // Note: We need to collect metadata asynchronously
448        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    /// Restore configuration from backup
462    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/// Dot folder management errors
493#[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
529/// Global dot manager instance
530static DOT_MANAGER: OnceLock<Mutex<DotManager>> = OnceLock::new();
531
532/// Get global dot manager instance
533pub 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
550/// Initialize dot folder (should be called at startup)
551pub async fn initialize_dot_folder() -> Result<(), DotError> {
552    let manager = clone_manager()?;
553    manager.initialize().await
554}
555
556/// Load user configuration
557pub async fn load_user_config() -> Result<DotConfig, DotError> {
558    let manager = clone_manager()?;
559    manager.load_config().await
560}
561
562/// Save user configuration
563pub async fn save_user_config(config: &DotConfig) -> Result<(), DotError> {
564    let manager = clone_manager()?;
565    manager.save_config(config).await
566}
567
568/// Load the trust level recorded for a workspace, if any.
569pub 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
576/// Persist the trust level recorded for a workspace.
577pub 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
585/// Persist the preferred UI theme in the user's dot configuration.
586pub 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
593/// Persist the preferred provider and model combination.
594pub 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        // Test directory creation
615        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}