Skip to main content

vtcode_config/defaults/
provider.rs

1use 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
12/// Empty by default — all ~250 syntect grammars are enabled.
13/// Users can set `enabled_languages` in vtcode.toml to restrict to a subset.
14static 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
60/// Provides access to filesystem and syntax defaults used by the configuration
61/// loader.
62pub trait ConfigDefaultsProvider: Send + Sync {
63    /// Returns the primary configuration file name expected in a workspace.
64    fn config_file_name(&self) -> &str {
65        DEFAULT_CONFIG_FILE_NAME
66    }
67
68    /// Creates a [`WorkspacePaths`] implementation for the provided workspace
69    /// root.
70    fn workspace_paths_for(&self, workspace_root: &Path) -> Box<dyn WorkspacePaths>;
71
72    /// Returns the fallback configuration locations searched outside the
73    /// workspace.
74    fn home_config_paths(&self, config_file_name: &str) -> Vec<PathBuf>;
75
76    /// Returns the default syntax highlighting theme identifier.
77    fn syntax_theme(&self) -> String;
78
79    /// Returns the default list of syntax highlighting languages.
80    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
104/// Installs a new [`ConfigDefaultsProvider`], returning the previous provider.
105pub 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
117/// Restores the built-in defaults provider.
118pub fn reset_to_default_config_defaults() {
119    let _ = install_config_defaults_provider(Arc::new(DefaultConfigDefaults));
120}
121
122/// Executes the provided function with the currently installed provider.
123pub 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
134/// Returns the currently installed provider as an [`Arc`].
135pub 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
162/// Get the XDG-compliant configuration directory for vtcode.
163///
164/// Follows the Ratatui recipe pattern for config directories:
165/// 1. Check environment variable VTCODE_CONFIG for custom location
166/// 2. Use XDG Base Directory Specification via ProjectDirs
167/// 3. Fallback to legacy ~/.vtcode/ for backwards compatibility
168///
169/// Returns `None` if no suitable directory can be determined.
170pub fn get_config_dir() -> Option<PathBuf> {
171    // Allow custom config directory via environment variable
172    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    // Use XDG-compliant directories (e.g., ~/.config/vtcode on Linux)
180    if let Some(proj_dirs) = ProjectDirs::from("com", "vinhnx", "vtcode") {
181        return Some(proj_dirs.config_local_dir().to_path_buf());
182    }
183
184    // Fallback to legacy ~/.vtcode/ for backwards compatibility
185    dirs::home_dir().map(|home| home.join(DEFAULT_CONFIG_DIR_NAME))
186}
187
188/// Get the XDG-compliant data directory for vtcode.
189///
190/// Follows the Ratatui recipe pattern for data directories:
191/// 1. Check environment variable VTCODE_DATA for custom location
192/// 2. Use XDG Base Directory Specification via ProjectDirs
193/// 3. Fallback to legacy ~/.vtcode/cache for backwards compatibility
194///
195/// Returns `None` if no suitable directory can be determined.
196pub fn get_data_dir() -> Option<PathBuf> {
197    // Allow custom data directory via environment variable
198    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    // Use XDG-compliant directories (e.g., ~/.local/share/vtcode on Linux)
206    if let Some(proj_dirs) = ProjectDirs::from("com", "vinhnx", "vtcode") {
207        return Some(proj_dirs.data_local_dir().to_path_buf());
208    }
209
210    // Fallback to legacy ~/.vtcode/cache for backwards compatibility
211    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/// Adapter that maps an existing [`WorkspacePaths`] implementation into a
259/// [`ConfigDefaultsProvider`].
260#[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    /// Creates a defaults provider that delegates to the supplied
277    /// [`WorkspacePaths`] implementation.
278    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    /// Overrides the configuration file name returned by the provider.
289    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    /// Overrides the fallback configuration search paths returned by the provider.
295    pub fn with_home_paths(mut self, home_paths: Vec<PathBuf>) -> Self {
296        self.home_paths = Some(home_paths);
297        self
298    }
299
300    /// Overrides the default syntax theme returned by the provider.
301    pub fn with_syntax_theme(mut self, theme: impl Into<String>) -> Self {
302        self.syntax_theme = theme.into();
303        self
304    }
305
306    /// Overrides the default syntax languages returned by the provider.
307    pub fn with_syntax_languages(mut self, languages: Vec<String>) -> Self {
308        self.syntax_languages = languages;
309        self
310    }
311
312    /// Consumes the builder, returning a boxed provider implementation.
313    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}