1pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct DotConfig {
16 pub version: String,
18 pub last_updated: u64,
20 pub preferences: UserPreferences,
22 pub providers: ProviderConfigs,
24 pub cache: CacheConfig,
26 pub ui: UiConfig,
28 #[serde(default)]
30 pub workspace_trust: WorkspaceTrustStore,
31 #[serde(default)]
33 pub lifecycle_hook_approvals: HashMap<String, LifecycleHookApprovalRecord>,
34 #[serde(default)]
36 pub dependency_notices: DependencyNoticeStore,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct UserPreferences {
42 pub default_model: String,
44 pub default_provider: String,
46 pub max_tokens: Option<u32>,
48 pub temperature: Option<f32>,
50 pub auto_save: bool,
52 pub theme: String,
54 pub keybindings: HashMap<String, String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct ProviderConfigs {
61 pub openai: Option<ProviderConfig>,
63 pub anthropic: Option<ProviderConfig>,
65 pub gemini: Option<ProviderConfig>,
67 pub deepseek: Option<ProviderConfig>,
69 pub meta: Option<ProviderConfig>,
71 pub openrouter: Option<ProviderConfig>,
73 pub ollama: Option<ProviderConfig>,
75 pub lmstudio: Option<ProviderConfig>,
77 pub llamacpp: Option<ProviderConfig>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub minimax: Option<ProviderConfig>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub stepfun: Option<ProviderConfig>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub evolink: Option<ProviderConfig>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub merge_gateway: Option<ProviderConfig>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct WorkspaceTrustStore {
96 #[serde(default)]
98 pub entries: HashMap<String, WorkspaceTrustRecord>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct WorkspaceTrustRecord {
104 pub level: WorkspaceTrustLevel,
106 pub trusted_at: u64,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct LifecycleHookApprovalRecord {
118 pub config_digest: String,
120 pub approved_at: u64,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, Default)]
126pub struct DependencyNoticeStore {
127 #[serde(default)]
129 pub ripgrep_missing_notice_shown: bool,
130 #[serde(default)]
132 pub ast_grep_missing_notice_shown: bool,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct ProviderConfig {
138 pub api_key: Option<String>,
140 pub base_url: Option<String>,
142 pub model: Option<String>,
144 pub enabled: bool,
146 pub priority: i32,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct CacheConfig {
153 pub enabled: bool,
155 pub max_size_mb: u64,
157 pub ttl_days: u64,
159 pub prompt_cache_enabled: bool,
161 pub context_cache_enabled: bool,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct UiConfig {
168 pub show_timestamps: bool,
170 pub max_output_lines: usize,
172 pub syntax_highlighting: bool,
174 pub auto_complete: bool,
176 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#[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 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 pub async fn initialize(&self) -> Result<(), DotError> {
259 ensure_user_dir(&self.config_dir)?;
261 ensure_user_dir(&self.cache_dir)?;
262 ensure_user_dir(&self.state_dir)?;
263
264 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 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 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 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 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 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 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 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 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 pub fn cache_dir(&self, cache_type: &str) -> PathBuf {
383 self.cache_dir.join(cache_type)
384 }
385
386 pub fn logs_dir(&self) -> PathBuf {
388 self.state_dir.join("logs")
389 }
390
391 pub fn sessions_dir(&self) -> PathBuf {
393 self.state_dir.join("sessions")
394 }
395
396 pub fn backups_dir(&self) -> PathBuf {
398 self.state_dir.join("backups")
399 }
400
401 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 if config.cache.prompt_cache_enabled {
411 stats.prompts_cleaned = self.cleanup_directory(&self.cache_dir("prompts"), max_age, now).await?;
412 }
413
414 if config.cache.context_cache_enabled {
416 stats.context_cleaned = self.cleanup_directory(&self.cache_dir("context"), max_age, now).await?;
417 }
418
419 stats.models_cleaned = self.cleanup_directory(&self.cache_dir("models"), max_age, now).await?;
421
422 Ok(stats)
423 }
424
425 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 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 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 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 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 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 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#[derive(Debug, Default)]
576pub struct CacheCleanupStats {
577 pub prompts_cleaned: u64,
579 pub context_cleaned: u64,
581 pub models_cleaned: u64,
583}
584
585#[derive(Debug, Default)]
587pub struct DiskUsageStats {
588 pub config_size: u64,
590 pub cache_size: u64,
592 pub logs_size: u64,
594 pub sessions_size: u64,
596 pub backups_size: u64,
598 pub total_size: u64,
600}
601
602#[derive(Debug, thiserror::Error)]
604pub enum DotError {
605 #[error("Home directory not found")]
607 HomeDirNotFound,
608
609 #[error("System time error: {0}")]
611 SystemTime(#[from] std::time::SystemTimeError),
612
613 #[error("IO error: {0}")]
615 Io(#[from] std::io::Error),
616
617 #[error("TOML serialization error: {0}")]
619 Toml(#[from] toml::ser::Error),
620
621 #[error("TOML deserialization error: {0}")]
623 TomlDe(#[from] toml::de::Error),
624
625 #[error("Backup not found: {0}")]
627 BackupNotFound(PathBuf),
628
629 #[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
642fn 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
703static DOT_MANAGER: OnceLock<Mutex<DotManager>> = OnceLock::new();
705static STARTUP_USER_CONFIG: OnceLock<Mutex<Option<DotConfig>>> = OnceLock::new();
706
707pub 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
723pub async fn initialize_dot_folder() -> Result<(), DotError> {
725 let manager = clone_manager()?;
726 manager.initialize().await
727}
728
729pub 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
741pub 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
750pub async fn load_user_config() -> Result<DotConfig, DotError> {
752 let manager = clone_manager()?;
753 manager.load_config().await
754}
755
756pub async fn save_user_config(config: &DotConfig) -> Result<(), DotError> {
758 let manager = clone_manager()?;
759 manager.save_config(config).await
760}
761
762pub 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
768pub 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
774pub 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
780pub 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
787pub 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
793pub 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 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 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 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}