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::utils::path::canonicalize_workspace;
6use hashbrown::HashMap;
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use std::sync::{Mutex, OnceLock};
10use tokio::fs;
11use vtcode_commons::VtCodePaths;
12
13/// VT Code's auxiliary user configuration and cache/state metadata.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct DotConfig {
16    /// Configuration schema version.
17    pub version: String,
18    /// Unix timestamp of the last update.
19    pub last_updated: u64,
20    /// User preference settings.
21    pub preferences: UserPreferences,
22    /// LLM provider configurations.
23    pub providers: ProviderConfigs,
24    /// Cache settings.
25    pub cache: CacheConfig,
26    /// UI configuration.
27    pub ui: UiConfig,
28    /// Workspace trust records.
29    #[serde(default)]
30    pub workspace_trust: WorkspaceTrustStore,
31    /// Per-workspace approvals for workspace-controlled lifecycle hooks.
32    #[serde(default)]
33    pub lifecycle_hook_approvals: HashMap<String, LifecycleHookApprovalRecord>,
34    /// Dependency notice display state.
35    #[serde(default)]
36    pub dependency_notices: DependencyNoticeStore,
37}
38
39/// User preference settings for the application.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct UserPreferences {
42    /// Default LLM model identifier.
43    pub default_model: String,
44    /// Default LLM provider name.
45    pub default_provider: String,
46    /// Maximum tokens for generation.
47    pub max_tokens: Option<u32>,
48    /// Sampling temperature.
49    pub temperature: Option<f32>,
50    /// Whether to auto-save on changes.
51    pub auto_save: bool,
52    /// UI theme name.
53    pub theme: String,
54    /// Custom keybinding mappings.
55    pub keybindings: HashMap<String, String>,
56}
57
58/// Configuration for all supported LLM providers.
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct ProviderConfigs {
61    /// OpenAI provider configuration.
62    pub openai: Option<ProviderConfig>,
63    /// Anthropic provider configuration.
64    pub anthropic: Option<ProviderConfig>,
65    /// Google Gemini provider configuration.
66    pub gemini: Option<ProviderConfig>,
67    /// DeepSeek provider configuration.
68    pub deepseek: Option<ProviderConfig>,
69    /// Official Meta AI provider configuration.
70    pub meta: Option<ProviderConfig>,
71    /// OpenRouter provider configuration.
72    pub openrouter: Option<ProviderConfig>,
73    /// Ollama provider configuration.
74    pub ollama: Option<ProviderConfig>,
75    /// LM Studio provider configuration.
76    pub lmstudio: Option<ProviderConfig>,
77    /// llama.cpp provider configuration.
78    pub llamacpp: Option<ProviderConfig>,
79    /// MiniMax provider configuration.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub minimax: Option<ProviderConfig>,
82    /// StepFun provider configuration.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub stepfun: Option<ProviderConfig>,
85    /// Evolink provider configuration.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub evolink: Option<ProviderConfig>,
88    /// Merge Gateway provider configuration.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub merge_gateway: Option<ProviderConfig>,
91}
92
93/// Store of workspace trust records.
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct WorkspaceTrustStore {
96    /// Map of workspace paths to their trust records.
97    #[serde(default)]
98    pub entries: HashMap<String, WorkspaceTrustRecord>,
99}
100
101/// Record of a workspace's trust level and when it was granted.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct WorkspaceTrustRecord {
104    /// Trust level assigned to the workspace.
105    pub level: WorkspaceTrustLevel,
106    /// Unix timestamp when trust was granted.
107    pub trusted_at: u64,
108}
109
110/// Per-workspace approval record for workspace-controlled lifecycle hooks.
111///
112/// The approval is bound to a digest of the exact workspace-sourced hook
113/// command set: if the configuration changes (e.g. a repository update alters
114/// `vtcode.toml`), the digest changes and the approval no longer matches, so
115/// the new commands are skipped until the user reviews them again.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct LifecycleHookApprovalRecord {
118    /// Digest of the approved workspace-controlled lifecycle hook commands.
119    pub config_digest: String,
120    /// Unix timestamp when the approval was granted.
121    pub approved_at: u64,
122}
123
124/// Store tracking which dependency notices have been shown.
125#[derive(Debug, Clone, Serialize, Deserialize, Default)]
126pub struct DependencyNoticeStore {
127    /// Whether the ripgrep missing notice has been shown.
128    #[serde(default)]
129    pub ripgrep_missing_notice_shown: bool,
130    /// Whether the ast-grep missing notice has been shown.
131    #[serde(default)]
132    pub ast_grep_missing_notice_shown: bool,
133}
134
135/// Configuration for an individual LLM provider.
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct ProviderConfig {
138    /// API key for authentication.
139    pub api_key: Option<String>,
140    /// Custom base URL for the API endpoint.
141    pub base_url: Option<String>,
142    /// Default model for this provider.
143    pub model: Option<String>,
144    /// Whether this provider is enabled.
145    pub enabled: bool,
146    /// Priority for provider selection (higher = preferred).
147    pub priority: i32,
148}
149
150/// Cache configuration settings.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct CacheConfig {
153    /// Whether caching is enabled.
154    pub enabled: bool,
155    /// Maximum cache size in megabytes.
156    pub max_size_mb: u64,
157    /// Time-to-live for cached entries in days.
158    pub ttl_days: u64,
159    /// Whether prompt caching is enabled.
160    pub prompt_cache_enabled: bool,
161    /// Whether context caching is enabled.
162    pub context_cache_enabled: bool,
163}
164
165/// UI configuration settings.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct UiConfig {
168    /// Whether to show timestamps in output.
169    pub show_timestamps: bool,
170    /// Maximum number of output lines to display.
171    pub max_output_lines: usize,
172    /// Whether syntax highlighting is enabled.
173    pub syntax_highlighting: bool,
174    /// Whether auto-completion is enabled.
175    pub auto_complete: bool,
176    /// Number of history entries to retain.
177    pub history_size: usize,
178}
179
180impl Default for DotConfig {
181    fn default() -> Self {
182        Self {
183            version: env!("CARGO_PKG_VERSION").into(),
184            last_updated: unix_timestamp_secs().unwrap_or(0),
185            preferences: UserPreferences::default(),
186            providers: ProviderConfigs::default(),
187            cache: CacheConfig::default(),
188            ui: UiConfig::default(),
189            workspace_trust: WorkspaceTrustStore::default(),
190            lifecycle_hook_approvals: HashMap::new(),
191            dependency_notices: DependencyNoticeStore::default(),
192        }
193    }
194}
195
196impl Default for UserPreferences {
197    fn default() -> Self {
198        Self {
199            default_model: defaults::DEFAULT_MODEL.into(),
200            default_provider: defaults::DEFAULT_PROVIDER.into(),
201            max_tokens: Some(4096),
202            temperature: Some(0.7),
203            auto_save: true,
204            theme: defaults::DEFAULT_THEME.into(),
205            keybindings: HashMap::new(),
206        }
207    }
208}
209
210impl Default for CacheConfig {
211    fn default() -> Self {
212        Self {
213            enabled: true,
214            max_size_mb: 100,
215            ttl_days: 30,
216            prompt_cache_enabled: true,
217            context_cache_enabled: true,
218        }
219    }
220}
221
222impl Default for UiConfig {
223    fn default() -> Self {
224        Self {
225            show_timestamps: true,
226            max_output_lines: 1000,
227            syntax_highlighting: true,
228            auto_complete: true,
229            history_size: 1000,
230        }
231    }
232}
233
234/// Dot folder manager for VT Code configuration and cache.
235#[derive(Clone)]
236pub struct DotManager {
237    config_dir: PathBuf,
238    cache_dir: PathBuf,
239    state_dir: PathBuf,
240    config_file: PathBuf,
241}
242
243impl DotManager {
244    /// Creates a new `DotManager` using the centralized VT Code path policy.
245    pub fn new() -> Result<Self, DotError> {
246        let paths = VtCodePaths::resolve().map_err(|_error| DotError::HomeDirNotFound)?;
247        let config_dir = paths.config_dir().to_path_buf();
248        let cache_dir = paths.cache_dir().to_path_buf();
249        let state_dir = paths.state_dir().to_path_buf();
250        let config_file = paths
251            .config_path("config.toml")
252            .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?;
253
254        Ok(Self { config_dir, cache_dir, state_dir, config_file })
255    }
256
257    /// Initialize the dot folder structure
258    pub async fn initialize(&self) -> Result<(), DotError> {
259        // Create directories
260        ensure_user_dir(&self.config_dir)?;
261        ensure_user_dir(&self.cache_dir)?;
262        ensure_user_dir(&self.state_dir)?;
263
264        // Create subdirectories
265        let subdirs = [
266            self.cache_dir.join("prompts"),
267            self.cache_dir.join("context"),
268            self.cache_dir.join("models"),
269            self.state_dir.join("logs"),
270            self.state_dir.join("sessions"),
271            self.state_dir.join("backups"),
272        ];
273
274        for subdir in subdirs {
275            ensure_user_dir(&subdir)?;
276        }
277
278        // Create default config if it doesn't exist
279        if !fs::try_exists(&self.config_file).await.unwrap_or(false) {
280            let default_config = DotConfig::default();
281            self.save_config(&default_config).await?;
282        }
283
284        Ok(())
285    }
286
287    /// Load configuration from disk
288    pub async fn load_config(&self) -> Result<DotConfig, DotError> {
289        if !fs::try_exists(&self.config_file).await.unwrap_or(false) {
290            return Ok(DotConfig::default());
291        }
292
293        let content = String::from_utf8(
294            vtcode_commons::fs::read_private_file_no_follow(&self.config_file)
295                .await
296                .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?,
297        )
298        .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?;
299
300        let mut value: toml::Value = toml::from_str(&content).map_err(DotError::TomlDe)?;
301        normalize_legacy_top_level_provider_aliases(&mut value);
302        value.try_into().map_err(|error: toml::de::Error| DotError::TomlDe(error))
303    }
304
305    /// Save configuration to disk.
306    ///
307    /// Writes to a temporary sibling file and renames it into place so a crash
308    /// or concurrent reader never observes a torn `config.toml`.
309    pub async fn save_config(&self, config: &DotConfig) -> Result<(), DotError> {
310        let content = toml::to_string_pretty(config).map_err(DotError::Toml)?;
311
312        let path = self.config_file.clone();
313        tokio::task::spawn_blocking(move || VtCodePaths::write_private_file_atomic(&path, content.as_bytes()))
314            .await
315            .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?
316            .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?;
317
318        Ok(())
319    }
320
321    /// Update configuration with new values
322    pub async fn update_config<F>(&self, updater: F) -> Result<(), DotError>
323    where
324        F: FnOnce(&mut DotConfig),
325    {
326        let mut config = self.load_config().await?;
327        updater(&mut config);
328        config.last_updated = unix_timestamp_secs()?;
329        self.save_config(&config).await
330    }
331
332    /// Load the trust level recorded for a workspace, if any.
333    pub async fn workspace_trust_level(&self, workspace: &Path) -> Result<Option<WorkspaceTrustLevel>, DotError> {
334        let workspace_key = workspace_trust_key(workspace);
335        let config = self.load_config().await?;
336
337        Ok(config.workspace_trust.entries.get(&workspace_key).map(|record| record.level))
338    }
339
340    /// Persist a workspace trust level in the dot configuration.
341    pub async fn update_workspace_trust(&self, workspace: &Path, level: WorkspaceTrustLevel) -> Result<(), DotError> {
342        let workspace_key = workspace_trust_key(workspace);
343        let trusted_at = unix_timestamp_secs()?;
344
345        self.update_config(|cfg| {
346            cfg.workspace_trust
347                .entries
348                .insert(workspace_key, WorkspaceTrustRecord { level, trusted_at });
349        })
350        .await
351    }
352
353    /// Load the lifecycle hook approval record for a workspace, if any.
354    pub async fn lifecycle_hook_approval(
355        &self,
356        workspace: &Path,
357    ) -> Result<Option<LifecycleHookApprovalRecord>, DotError> {
358        let workspace_key = workspace_trust_key(workspace);
359        let config = self.load_config().await?;
360
361        Ok(config.lifecycle_hook_approvals.get(&workspace_key).cloned())
362    }
363
364    /// Persist an approval of the current workspace-controlled lifecycle hook
365    /// command set, replacing any earlier approval for the workspace.
366    pub async fn update_lifecycle_hook_approval(
367        &self,
368        workspace: &Path,
369        config_digest: String,
370    ) -> Result<(), DotError> {
371        let workspace_key = workspace_trust_key(workspace);
372        let approved_at = unix_timestamp_secs()?;
373
374        self.update_config(|cfg| {
375            cfg.lifecycle_hook_approvals
376                .insert(workspace_key, LifecycleHookApprovalRecord { config_digest, approved_at });
377        })
378        .await
379    }
380
381    /// Get cache directory for a specific type
382    pub fn cache_dir(&self, cache_type: &str) -> PathBuf {
383        self.cache_dir.join(cache_type)
384    }
385
386    /// Get logs directory
387    pub fn logs_dir(&self) -> PathBuf {
388        self.state_dir.join("logs")
389    }
390
391    /// Get sessions directory
392    pub fn sessions_dir(&self) -> PathBuf {
393        self.state_dir.join("sessions")
394    }
395
396    /// Get backups directory
397    pub fn backups_dir(&self) -> PathBuf {
398        self.state_dir.join("backups")
399    }
400
401    /// Clean up old cache files
402    pub async fn cleanup_cache(&self) -> Result<CacheCleanupStats, DotError> {
403        let config = self.load_config().await?;
404        let max_age = std::time::Duration::from_secs(config.cache.ttl_days * 24 * 60 * 60);
405        let now = std::time::SystemTime::now();
406
407        let mut stats = CacheCleanupStats::default();
408
409        // Clean prompt cache
410        if config.cache.prompt_cache_enabled {
411            stats.prompts_cleaned = self.cleanup_directory(&self.cache_dir("prompts"), max_age, now).await?;
412        }
413
414        // Clean context cache
415        if config.cache.context_cache_enabled {
416            stats.context_cleaned = self.cleanup_directory(&self.cache_dir("context"), max_age, now).await?;
417        }
418
419        // Clean model cache
420        stats.models_cleaned = self.cleanup_directory(&self.cache_dir("models"), max_age, now).await?;
421
422        Ok(stats)
423    }
424
425    /// Clean up files in a directory older than max_age
426    async fn cleanup_directory(
427        &self,
428        dir: &Path,
429        max_age: std::time::Duration,
430        now: std::time::SystemTime,
431    ) -> Result<u64, DotError> {
432        if !fs::try_exists(dir).await.unwrap_or(false) {
433            return Ok(0);
434        }
435
436        let mut cleaned = 0u64;
437        let mut entries = fs::read_dir(dir).await.map_err(DotError::Io)?;
438
439        while let Ok(Some(entry)) = entries.next_entry().await {
440            let path = entry.path();
441
442            if let Ok(metadata) = entry.metadata().await
443                && let Ok(modified) = metadata.modified()
444                && let Ok(age) = now.duration_since(modified)
445                && age > max_age
446            {
447                if path.is_file() {
448                    fs::remove_file(&path).await.map_err(DotError::Io)?;
449                    cleaned += 1;
450                } else if path.is_dir() {
451                    fs::remove_dir_all(&path).await.map_err(DotError::Io)?;
452                    cleaned += 1;
453                }
454            }
455        }
456
457        Ok(cleaned)
458    }
459
460    /// Get disk usage statistics
461    pub async fn disk_usage(&self) -> Result<DiskUsageStats, DotError> {
462        let mut stats = DiskUsageStats::default();
463
464        stats.config_size = self.calculate_dir_size(&self.config_dir).await?;
465        stats.cache_size = self.calculate_dir_size(&self.cache_dir).await?;
466        stats.logs_size = self.calculate_dir_size(&self.logs_dir()).await?;
467        stats.sessions_size = self.calculate_dir_size(&self.sessions_dir()).await?;
468        stats.backups_size = self.calculate_dir_size(&self.backups_dir()).await?;
469
470        stats.total_size =
471            stats.config_size + stats.cache_size + stats.logs_size + stats.sessions_size + stats.backups_size;
472
473        Ok(stats)
474    }
475
476    /// Calculate directory size recursively
477    async fn calculate_dir_size(&self, dir: &Path) -> Result<u64, DotError> {
478        if !fs::try_exists(dir).await.unwrap_or(false) {
479            return Ok(0);
480        }
481
482        let mut size = 0u64;
483
484        fn calculate_recursive<'a>(
485            path: &'a Path,
486            current_size: &'a mut u64,
487        ) -> std::pin::Pin<Box<dyn Future<Output = Result<(), DotError>> + Send + 'a>> {
488            Box::pin(async move {
489                let metadata = fs::metadata(path).await.map_err(DotError::Io)?;
490                if metadata.is_file() {
491                    *current_size += metadata.len();
492                } else if metadata.is_dir() {
493                    let mut entries = fs::read_dir(path).await.map_err(DotError::Io)?;
494                    while let Ok(Some(entry)) = entries.next_entry().await {
495                        calculate_recursive(&entry.path(), current_size).await?;
496                    }
497                }
498                Ok(())
499            })
500        }
501
502        calculate_recursive(dir, &mut size).await?;
503        Ok(size)
504    }
505
506    /// Backup current configuration
507    pub async fn backup_config(&self) -> Result<PathBuf, DotError> {
508        let timestamp = unix_timestamp_secs()?;
509
510        let backup_name = format!("config_backup_{timestamp}.toml");
511        let backup_path = self.backups_dir().join(backup_name);
512
513        if fs::try_exists(&self.config_file).await.unwrap_or(false) {
514            let content = vtcode_commons::fs::read_private_file_no_follow(&self.config_file)
515                .await
516                .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?;
517            let backup_destination = backup_path.clone();
518            tokio::task::spawn_blocking(move || VtCodePaths::write_private_file_atomic(&backup_destination, &content))
519                .await
520                .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?
521                .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?;
522        }
523
524        Ok(backup_path)
525    }
526
527    /// List available backups
528    pub async fn list_backups(&self) -> Result<Vec<PathBuf>, DotError> {
529        let backups_dir = self.backups_dir();
530        if !fs::try_exists(&backups_dir).await.unwrap_or(false) {
531            return Ok(vec![]);
532        }
533
534        let mut backups = vec![];
535        let mut entries = fs::read_dir(backups_dir).await.map_err(DotError::Io)?;
536
537        while let Ok(Some(entry)) = entries.next_entry().await {
538            if entry.path().extension().and_then(|e| e.to_str()) == Some("toml") {
539                backups.push(entry.path());
540            }
541        }
542
543        // Sort by modification time (newest first)
544        // Note: We need to collect metadata asynchronously
545        let mut backup_times = Vec::new();
546        for backup in &backups {
547            let time = fs::metadata(backup).await.ok().and_then(|m| m.modified().ok());
548            backup_times.push((backup.clone(), time));
549        }
550        backup_times.sort_by_key(|a| std::cmp::Reverse(a.1));
551
552        Ok(backup_times.into_iter().map(|(path, _)| path).collect())
553    }
554
555    /// Restore configuration from backup
556    pub async fn restore_backup(&self, backup_path: &Path) -> Result<(), DotError> {
557        if !fs::try_exists(backup_path).await.unwrap_or(false) {
558            return Err(DotError::BackupNotFound(backup_path.to_path_buf()));
559        }
560
561        let content = vtcode_commons::fs::read_private_file_no_follow(backup_path)
562            .await
563            .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?;
564        let path = self.config_file.clone();
565        tokio::task::spawn_blocking(move || VtCodePaths::write_private_file_atomic(&path, &content))
566            .await
567            .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?
568            .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))?;
569
570        Ok(())
571    }
572}
573
574/// Statistics from a cache cleanup operation.
575#[derive(Debug, Default)]
576pub struct CacheCleanupStats {
577    /// Number of prompt cache entries cleaned.
578    pub prompts_cleaned: u64,
579    /// Number of context cache entries cleaned.
580    pub context_cleaned: u64,
581    /// Number of model cache entries cleaned.
582    pub models_cleaned: u64,
583}
584
585/// Disk usage statistics for the dot folder.
586#[derive(Debug, Default)]
587pub struct DiskUsageStats {
588    /// Size of the configuration directory in bytes.
589    pub config_size: u64,
590    /// Size of the cache directory in bytes.
591    pub cache_size: u64,
592    /// Size of the logs directory in bytes.
593    pub logs_size: u64,
594    /// Size of the sessions directory in bytes.
595    pub sessions_size: u64,
596    /// Size of the backups directory in bytes.
597    pub backups_size: u64,
598    /// Total size across all directories in bytes.
599    pub total_size: u64,
600}
601
602/// Errors that can occur during dot folder operations.
603#[derive(Debug, thiserror::Error)]
604pub enum DotError {
605    /// The user's home directory could not be determined.
606    #[error("Home directory not found")]
607    HomeDirNotFound,
608
609    /// A system time error occurred.
610    #[error("System time error: {0}")]
611    SystemTime(#[from] std::time::SystemTimeError),
612
613    /// An I/O error occurred.
614    #[error("IO error: {0}")]
615    Io(#[from] std::io::Error),
616
617    /// TOML serialization failed.
618    #[error("TOML serialization error: {0}")]
619    Toml(#[from] toml::ser::Error),
620
621    /// TOML deserialization failed.
622    #[error("TOML deserialization error: {0}")]
623    TomlDe(#[from] toml::de::Error),
624
625    /// The specified backup file was not found.
626    #[error("Backup not found: {0}")]
627    BackupNotFound(PathBuf),
628
629    /// The dot manager mutex was poisoned.
630    #[error("Dot manager lock poisoned: {0}")]
631    LockPoisoned(String),
632}
633
634fn unix_timestamp_secs() -> Result<u64, DotError> {
635    Ok(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs())
636}
637
638fn workspace_trust_key(workspace: &Path) -> String {
639    canonicalize_workspace(workspace).to_string_lossy().into_owned()
640}
641
642/// Promote legacy bare `default_provider` / `default_model` keys at the
643/// document root of the auxiliary `config.toml` into `[preferences]`.
644///
645/// Hand-edited global files commonly contain:
646///
647/// ```toml
648/// default_provider = "ollama"
649/// ```
650///
651/// `DotConfig` only deserializes `preferences.default_provider` /
652/// `preferences.default_model`, so without this promotion the bare keys are
653/// silently dropped and the runtime keeps the previous (often openrouter)
654/// selection. An explicit `[preferences]` entry in the same file wins over
655/// its own top-level alias.
656fn normalize_legacy_top_level_provider_aliases(value: &mut toml::Value) {
657    let Some(table) = value.as_table_mut() else {
658        return;
659    };
660
661    let legacy_provider = table
662        .get("default_provider")
663        .and_then(|v| v.as_str())
664        .map(str::trim)
665        .filter(|s| !s.is_empty())
666        .map(ToOwned::to_owned);
667    let legacy_model = table
668        .get("default_model")
669        .and_then(|v| v.as_str())
670        .map(str::trim)
671        .filter(|s| !s.is_empty())
672        .map(ToOwned::to_owned);
673
674    if legacy_provider.is_none() && legacy_model.is_none() {
675        return;
676    }
677
678    let prefs_entry = table
679        .entry("preferences".to_string())
680        .or_insert(toml::Value::Table(toml::Table::new()));
681    let Some(prefs_table) = prefs_entry.as_table_mut() else {
682        return;
683    };
684
685    if let Some(provider) = legacy_provider
686        && !prefs_table.contains_key("default_provider")
687    {
688        prefs_table.insert("default_provider".to_string(), toml::Value::String(provider));
689    }
690    if let Some(model) = legacy_model
691        && !prefs_table.contains_key("default_model")
692    {
693        prefs_table.insert("default_model".to_string(), toml::Value::String(model));
694    }
695}
696
697fn ensure_user_dir(path: &Path) -> Result<(), DotError> {
698    VtCodePaths::ensure_user_dir(path)
699        .map(|_| ())
700        .map_err(|error| DotError::Io(std::io::Error::other(error.to_string())))
701}
702
703/// Global dot manager instance
704static DOT_MANAGER: OnceLock<Mutex<DotManager>> = OnceLock::new();
705static STARTUP_USER_CONFIG: OnceLock<Mutex<Option<DotConfig>>> = OnceLock::new();
706
707/// Get global dot manager instance
708pub fn get_dot_manager() -> Result<&'static Mutex<DotManager>, DotError> {
709    if let Some(manager) = DOT_MANAGER.get() {
710        return Ok(manager);
711    }
712
713    let manager = DotManager::new()?;
714    Ok(DOT_MANAGER.get_or_init(|| Mutex::new(manager)))
715}
716
717fn clone_manager() -> Result<DotManager, DotError> {
718    let manager = get_dot_manager()?;
719    let guard = manager.lock().map_err(|err| DotError::LockPoisoned(err.to_string()))?;
720    Ok(guard.clone())
721}
722
723/// Initialize dot folder (should be called at startup)
724pub async fn initialize_dot_folder() -> Result<(), DotError> {
725    let manager = clone_manager()?;
726    manager.initialize().await
727}
728
729/// Cache the user configuration already read during startup for session setup.
730///
731/// The interactive UI consumes this snapshot for keybindings instead of
732/// parsing the same file a second time. A missing snapshot is valid for
733/// embedded callers that enter session setup without the normal CLI bootstrap.
734pub fn set_startup_user_config(config: Option<DotConfig>) {
735    let cache = STARTUP_USER_CONFIG.get_or_init(|| Mutex::new(None));
736    if let Ok(mut cached) = cache.lock() {
737        *cached = config;
738    }
739}
740
741/// Take the startup user-config snapshot, if one was prepared by the CLI.
742pub fn take_startup_user_config() -> Option<DotConfig> {
743    STARTUP_USER_CONFIG
744        .get_or_init(|| Mutex::new(None))
745        .lock()
746        .ok()
747        .and_then(|mut cached| cached.take())
748}
749
750/// Load user configuration
751pub async fn load_user_config() -> Result<DotConfig, DotError> {
752    let manager = clone_manager()?;
753    manager.load_config().await
754}
755
756/// Save user configuration
757pub async fn save_user_config(config: &DotConfig) -> Result<(), DotError> {
758    let manager = clone_manager()?;
759    manager.save_config(config).await
760}
761
762/// Load the trust level recorded for a workspace, if any.
763pub async fn load_workspace_trust_level(workspace: &Path) -> Result<Option<WorkspaceTrustLevel>, DotError> {
764    let manager = clone_manager()?;
765    manager.workspace_trust_level(workspace).await
766}
767
768/// Persist the trust level recorded for a workspace.
769pub async fn update_workspace_trust(workspace: &Path, level: WorkspaceTrustLevel) -> Result<(), DotError> {
770    let manager = clone_manager()?;
771    manager.update_workspace_trust(workspace, level).await
772}
773
774/// Load the lifecycle hook approval record recorded for a workspace, if any.
775pub async fn load_lifecycle_hook_approval(workspace: &Path) -> Result<Option<LifecycleHookApprovalRecord>, DotError> {
776    let manager = clone_manager()?;
777    manager.lifecycle_hook_approval(workspace).await
778}
779
780/// Persist an approval of the current workspace-controlled lifecycle hook
781/// command set for a workspace.
782pub async fn update_lifecycle_hook_approval(workspace: &Path, config_digest: String) -> Result<(), DotError> {
783    let manager = clone_manager()?;
784    manager.update_lifecycle_hook_approval(workspace, config_digest).await
785}
786
787/// Persist the preferred UI theme in the user's dot configuration.
788pub async fn update_theme_preference(theme: &str) -> Result<(), DotError> {
789    let manager = clone_manager()?;
790    manager.update_config(|cfg| cfg.preferences.theme = theme.to_string()).await
791}
792
793/// Persist the preferred provider and model combination.
794pub async fn update_model_preference(provider: &str, model: &str) -> Result<(), DotError> {
795    let manager = clone_manager()?;
796    manager
797        .update_config(|cfg| {
798            cfg.preferences.default_provider = provider.to_string();
799            cfg.preferences.default_model = model.to_string();
800        })
801        .await
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use tempfile::TempDir;
808
809    #[tokio::test]
810    async fn test_dot_manager_initialization() {
811        let temp_dir = TempDir::new().unwrap();
812        let config_dir = temp_dir.path().join(".vtcode");
813
814        // Test directory creation
815        assert!(!config_dir.exists());
816
817        let manager = DotManager {
818            config_dir: config_dir.clone(),
819            cache_dir: config_dir.join("cache"),
820            state_dir: config_dir.join("state"),
821            config_file: config_dir.join("config.toml"),
822        };
823
824        manager.initialize().await.unwrap();
825        assert!(config_dir.exists());
826        assert!(config_dir.join("cache").exists());
827        assert!(manager.logs_dir().exists());
828    }
829
830    #[tokio::test]
831    async fn test_config_save_load() {
832        let temp_dir = TempDir::new().unwrap();
833        let config_dir = temp_dir.path().join(".vtcode");
834
835        let manager = DotManager {
836            config_dir: config_dir.clone(),
837            cache_dir: config_dir.join("cache"),
838            state_dir: config_dir.join("state"),
839            config_file: config_dir.join("config.toml"),
840        };
841
842        manager.initialize().await.unwrap();
843
844        let mut config = DotConfig::default();
845        config.preferences.default_model = "test-model".to_owned();
846
847        manager.save_config(&config).await.unwrap();
848        let loaded_config = manager.load_config().await.unwrap();
849
850        assert_eq!(loaded_config.preferences.default_model, "test-model");
851    }
852
853    #[tokio::test]
854    async fn lifecycle_hook_approval_round_trip() {
855        let temp_dir = TempDir::new().unwrap();
856        let config_dir = temp_dir.path().join(".vtcode");
857
858        let manager = DotManager {
859            config_dir: config_dir.clone(),
860            cache_dir: config_dir.join("cache"),
861            state_dir: config_dir.join("state"),
862            config_file: config_dir.join("config.toml"),
863        };
864        manager.initialize().await.unwrap();
865
866        let workspace = temp_dir.path().join("ws");
867        std::fs::create_dir_all(&workspace).unwrap();
868
869        assert!(
870            manager.lifecycle_hook_approval(&workspace).await.unwrap().is_none(),
871            "no approval before any is granted"
872        );
873
874        manager
875            .update_lifecycle_hook_approval(&workspace, "digest-1".to_owned())
876            .await
877            .unwrap();
878        let record = manager.lifecycle_hook_approval(&workspace).await.unwrap().unwrap();
879        assert_eq!(record.config_digest, "digest-1");
880
881        // A newer approval replaces the earlier one for the same workspace.
882        manager
883            .update_lifecycle_hook_approval(&workspace, "digest-2".to_owned())
884            .await
885            .unwrap();
886        let record = manager.lifecycle_hook_approval(&workspace).await.unwrap().unwrap();
887        assert_eq!(record.config_digest, "digest-2");
888
889        // A different workspace keeps its own (absent) record.
890        let other_workspace = temp_dir.path().join("other");
891        std::fs::create_dir_all(&other_workspace).unwrap();
892        assert!(manager.lifecycle_hook_approval(&other_workspace).await.unwrap().is_none());
893    }
894
895    #[test]
896    fn legacy_top_level_default_provider_promotes_to_preferences() {
897        let mut value: toml::Value = toml::from_str("default_provider = \"ollama\"\n").expect("legacy toml");
898        normalize_legacy_top_level_provider_aliases(&mut value);
899        assert_eq!(
900            value
901                .get("preferences")
902                .and_then(|prefs| prefs.get("default_provider"))
903                .and_then(|v| v.as_str()),
904            Some("ollama")
905        );
906    }
907
908    #[test]
909    fn explicit_preferences_win_over_legacy_top_level_alias() {
910        let mut value: toml::Value =
911            toml::from_str("default_provider = \"ollama\"\n[preferences]\ndefault_provider = \"openai\"\n")
912                .expect("mixed toml");
913        normalize_legacy_top_level_provider_aliases(&mut value);
914        assert_eq!(
915            value
916                .get("preferences")
917                .and_then(|prefs| prefs.get("default_provider"))
918                .and_then(|v| v.as_str()),
919            Some("openai")
920        );
921    }
922}