vtcode_config/defaults/
provider.rs1use std::path::{Path, PathBuf};
2use std::sync::{Arc, RwLock};
3
4use directories::ProjectDirs;
5use once_cell::sync::Lazy;
6use vtcode_commons::paths::WorkspacePaths;
7
8const DEFAULT_CONFIG_FILE_NAME: &str = "vtcode.toml";
9const DEFAULT_CONFIG_DIR_NAME: &str = ".vtcode";
10const DEFAULT_SYNTAX_THEME: &str = "base16-ocean.dark";
11
12static DEFAULT_SYNTAX_LANGUAGES: Lazy<Vec<String>> = Lazy::new(Vec::new);
15
16static CONFIG_DEFAULTS: Lazy<RwLock<Arc<dyn ConfigDefaultsProvider>>> =
17 Lazy::new(|| RwLock::new(Arc::new(DefaultConfigDefaults)));
18
19#[cfg(test)]
20mod test_env_overrides {
21 use std::collections::HashMap;
22 use std::sync::{LazyLock, Mutex};
23
24 static OVERRIDES: LazyLock<Mutex<HashMap<String, Option<String>>>> =
25 LazyLock::new(|| Mutex::new(HashMap::new()));
26
27 pub(super) fn get(key: &str) -> Option<Option<String>> {
28 OVERRIDES.lock().ok().and_then(|map| map.get(key).cloned())
29 }
30
31 pub(super) fn set(key: &str, value: Option<&str>) {
32 if let Ok(mut map) = OVERRIDES.lock() {
33 map.insert(key.to_string(), value.map(ToString::to_string));
34 }
35 }
36
37 pub(super) fn restore(key: &str, previous: Option<Option<String>>) {
38 if let Ok(mut map) = OVERRIDES.lock() {
39 match previous {
40 Some(value) => {
41 map.insert(key.to_string(), value);
42 }
43 None => {
44 map.remove(key);
45 }
46 }
47 }
48 }
49}
50
51fn read_env_var(key: &str) -> Option<String> {
52 #[cfg(test)]
53 if let Some(override_value) = test_env_overrides::get(key) {
54 return override_value;
55 }
56
57 std::env::var(key).ok()
58}
59
60pub trait ConfigDefaultsProvider: Send + Sync {
63 fn config_file_name(&self) -> &str {
65 DEFAULT_CONFIG_FILE_NAME
66 }
67
68 fn workspace_paths_for(&self, workspace_root: &Path) -> Box<dyn WorkspacePaths>;
71
72 fn home_config_paths(&self, config_file_name: &str) -> Vec<PathBuf>;
75
76 fn syntax_theme(&self) -> String;
78
79 fn syntax_languages(&self) -> Vec<String>;
81}
82
83#[derive(Debug, Default)]
84struct DefaultConfigDefaults;
85
86impl ConfigDefaultsProvider for DefaultConfigDefaults {
87 fn workspace_paths_for(&self, workspace_root: &Path) -> Box<dyn WorkspacePaths> {
88 Box::new(DefaultWorkspacePaths::new(workspace_root.to_path_buf()))
89 }
90
91 fn home_config_paths(&self, config_file_name: &str) -> Vec<PathBuf> {
92 default_home_paths(config_file_name)
93 }
94
95 fn syntax_theme(&self) -> String {
96 DEFAULT_SYNTAX_THEME.to_string()
97 }
98
99 fn syntax_languages(&self) -> Vec<String> {
100 default_syntax_languages()
101 }
102}
103
104pub fn install_config_defaults_provider(
106 provider: Arc<dyn ConfigDefaultsProvider>,
107) -> Arc<dyn ConfigDefaultsProvider> {
108 let mut guard = CONFIG_DEFAULTS.write().unwrap_or_else(|poisoned| {
109 tracing::warn!(
110 "config defaults provider lock poisoned while installing provider; recovering"
111 );
112 poisoned.into_inner()
113 });
114 std::mem::replace(&mut *guard, provider)
115}
116
117pub fn reset_to_default_config_defaults() {
119 let _ = install_config_defaults_provider(Arc::new(DefaultConfigDefaults));
120}
121
122pub fn with_config_defaults<F, R>(operation: F) -> R
124where
125 F: FnOnce(&dyn ConfigDefaultsProvider) -> R,
126{
127 let guard = CONFIG_DEFAULTS.read().unwrap_or_else(|poisoned| {
128 tracing::warn!("config defaults provider lock poisoned while reading provider; recovering");
129 poisoned.into_inner()
130 });
131 operation(guard.as_ref())
132}
133
134pub fn current_config_defaults() -> Arc<dyn ConfigDefaultsProvider> {
136 let guard = CONFIG_DEFAULTS.read().unwrap_or_else(|poisoned| {
137 tracing::warn!("config defaults provider lock poisoned while cloning provider; recovering");
138 poisoned.into_inner()
139 });
140 Arc::clone(&*guard)
141}
142
143pub fn with_config_defaults_provider_for_test<F, R>(
144 provider: Arc<dyn ConfigDefaultsProvider>,
145 action: F,
146) -> R
147where
148 F: FnOnce() -> R,
149{
150 use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
151
152 let previous = install_config_defaults_provider(provider);
153 let result = catch_unwind(AssertUnwindSafe(action));
154 let _ = install_config_defaults_provider(previous);
155
156 match result {
157 Ok(value) => value,
158 Err(payload) => resume_unwind(payload),
159 }
160}
161
162pub fn get_config_dir() -> Option<PathBuf> {
171 if let Some(custom_dir) = read_env_var("VTCODE_CONFIG") {
173 let trimmed = custom_dir.trim();
174 if !trimmed.is_empty() {
175 return Some(PathBuf::from(trimmed));
176 }
177 }
178
179 if let Some(proj_dirs) = ProjectDirs::from("com", "vinhnx", "vtcode") {
181 return Some(proj_dirs.config_local_dir().to_path_buf());
182 }
183
184 dirs::home_dir().map(|home| home.join(DEFAULT_CONFIG_DIR_NAME))
186}
187
188pub fn get_data_dir() -> Option<PathBuf> {
197 if let Some(custom_dir) = read_env_var("VTCODE_DATA") {
199 let trimmed = custom_dir.trim();
200 if !trimmed.is_empty() {
201 return Some(PathBuf::from(trimmed));
202 }
203 }
204
205 if let Some(proj_dirs) = ProjectDirs::from("com", "vinhnx", "vtcode") {
207 return Some(proj_dirs.data_local_dir().to_path_buf());
208 }
209
210 dirs::home_dir().map(|home| home.join(DEFAULT_CONFIG_DIR_NAME).join("cache"))
212}
213
214fn default_home_paths(config_file_name: &str) -> Vec<PathBuf> {
215 get_config_dir()
216 .map(|config_dir| config_dir.join(config_file_name))
217 .into_iter()
218 .collect()
219}
220
221fn default_syntax_languages() -> Vec<String> {
222 DEFAULT_SYNTAX_LANGUAGES.clone()
223}
224
225#[derive(Debug, Clone)]
226struct DefaultWorkspacePaths {
227 root: PathBuf,
228}
229
230impl DefaultWorkspacePaths {
231 fn new(root: PathBuf) -> Self {
232 Self { root }
233 }
234
235 fn config_dir_path(&self) -> PathBuf {
236 self.root.join(DEFAULT_CONFIG_DIR_NAME)
237 }
238}
239
240impl WorkspacePaths for DefaultWorkspacePaths {
241 fn workspace_root(&self) -> &Path {
242 &self.root
243 }
244
245 fn config_dir(&self) -> PathBuf {
246 self.config_dir_path()
247 }
248
249 fn cache_dir(&self) -> Option<PathBuf> {
250 Some(self.config_dir_path().join("cache"))
251 }
252
253 fn telemetry_dir(&self) -> Option<PathBuf> {
254 Some(self.config_dir_path().join("telemetry"))
255 }
256}
257
258#[derive(Debug, Clone)]
261pub struct WorkspacePathsDefaults<P>
262where
263 P: WorkspacePaths + ?Sized,
264{
265 paths: Arc<P>,
266 config_file_name: String,
267 home_paths: Option<Vec<PathBuf>>,
268 syntax_theme: String,
269 syntax_languages: Vec<String>,
270}
271
272impl<P> WorkspacePathsDefaults<P>
273where
274 P: WorkspacePaths + 'static,
275{
276 pub fn new(paths: Arc<P>) -> Self {
279 Self {
280 paths,
281 config_file_name: DEFAULT_CONFIG_FILE_NAME.to_string(),
282 home_paths: None,
283 syntax_theme: DEFAULT_SYNTAX_THEME.to_string(),
284 syntax_languages: default_syntax_languages(),
285 }
286 }
287
288 pub fn with_config_file_name(mut self, file_name: impl Into<String>) -> Self {
290 self.config_file_name = file_name.into();
291 self
292 }
293
294 pub fn with_home_paths(mut self, home_paths: Vec<PathBuf>) -> Self {
296 self.home_paths = Some(home_paths);
297 self
298 }
299
300 pub fn with_syntax_theme(mut self, theme: impl Into<String>) -> Self {
302 self.syntax_theme = theme.into();
303 self
304 }
305
306 pub fn with_syntax_languages(mut self, languages: Vec<String>) -> Self {
308 self.syntax_languages = languages;
309 self
310 }
311
312 pub fn build(self) -> Box<dyn ConfigDefaultsProvider> {
314 Box::new(self)
315 }
316}
317
318impl<P> ConfigDefaultsProvider for WorkspacePathsDefaults<P>
319where
320 P: WorkspacePaths + 'static,
321{
322 fn config_file_name(&self) -> &str {
323 &self.config_file_name
324 }
325
326 fn workspace_paths_for(&self, _workspace_root: &Path) -> Box<dyn WorkspacePaths> {
327 Box::new(WorkspacePathsWrapper {
328 inner: Arc::clone(&self.paths),
329 })
330 }
331
332 fn home_config_paths(&self, config_file_name: &str) -> Vec<PathBuf> {
333 self.home_paths
334 .clone()
335 .unwrap_or_else(|| default_home_paths(config_file_name))
336 }
337
338 fn syntax_theme(&self) -> String {
339 self.syntax_theme.clone()
340 }
341
342 fn syntax_languages(&self) -> Vec<String> {
343 self.syntax_languages.clone()
344 }
345}
346
347#[derive(Debug, Clone)]
348struct WorkspacePathsWrapper<P>
349where
350 P: WorkspacePaths + ?Sized,
351{
352 inner: Arc<P>,
353}
354
355impl<P> WorkspacePaths for WorkspacePathsWrapper<P>
356where
357 P: WorkspacePaths + ?Sized,
358{
359 fn workspace_root(&self) -> &Path {
360 self.inner.workspace_root()
361 }
362
363 fn config_dir(&self) -> PathBuf {
364 self.inner.config_dir()
365 }
366
367 fn cache_dir(&self) -> Option<PathBuf> {
368 self.inner.cache_dir()
369 }
370
371 fn telemetry_dir(&self) -> Option<PathBuf> {
372 self.inner.telemetry_dir()
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::{get_config_dir, get_data_dir};
379 use serial_test::serial;
380 use std::path::PathBuf;
381
382 fn with_env_var<F>(key: &str, value: Option<&str>, f: F)
383 where
384 F: FnOnce(),
385 {
386 let previous = super::test_env_overrides::get(key);
387 super::test_env_overrides::set(key, value);
388
389 f();
390
391 super::test_env_overrides::restore(key, previous);
392 }
393
394 #[test]
395 #[serial]
396 fn get_config_dir_uses_env_override() {
397 with_env_var("VTCODE_CONFIG", Some("/tmp/vtcode-config-test"), || {
398 assert_eq!(
399 get_config_dir(),
400 Some(PathBuf::from("/tmp/vtcode-config-test"))
401 );
402 });
403 }
404
405 #[test]
406 #[serial]
407 fn get_data_dir_uses_env_override() {
408 with_env_var("VTCODE_DATA", Some("/tmp/vtcode-data-test"), || {
409 assert_eq!(get_data_dir(), Some(PathBuf::from("/tmp/vtcode-data-test")));
410 });
411 }
412
413 #[test]
414 #[serial]
415 fn get_config_dir_ignores_blank_env_override() {
416 with_env_var("VTCODE_CONFIG", Some(" "), || {
417 let resolved = get_config_dir();
418 assert!(resolved.is_some());
419 assert_ne!(resolved, Some(PathBuf::from(" ")));
420 assert_ne!(resolved, Some(PathBuf::new()));
421 });
422 }
423
424 #[test]
425 #[serial]
426 fn get_data_dir_ignores_blank_env_override() {
427 with_env_var("VTCODE_DATA", Some(" "), || {
428 let resolved = get_data_dir();
429 assert!(resolved.is_some());
430 assert_ne!(resolved, Some(PathBuf::from(" ")));
431 assert_ne!(resolved, Some(PathBuf::new()));
432 });
433 }
434
435 #[test]
436 #[serial]
437 fn env_guard_restores_original_value() {
438 let key = "VTCODE_CONFIG";
439 let initial = super::read_env_var(key);
440 with_env_var(key, Some("/tmp/vtcode-config-test"), || {
441 assert_eq!(
442 super::read_env_var(key),
443 Some("/tmp/vtcode-config-test".to_string())
444 );
445 });
446 assert_eq!(super::read_env_var(key), initial);
447 }
448}