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
19fn read_env_var(key: &str) -> Option<String> {
20    crate::env_helpers::read_env_var(key)
21}
22
23/// Provides access to filesystem and syntax defaults used by the configuration
24/// loader.
25pub trait ConfigDefaultsProvider: Send + Sync {
26    /// Returns the primary configuration file name expected in a workspace.
27    fn config_file_name(&self) -> &str {
28        DEFAULT_CONFIG_FILE_NAME
29    }
30
31    /// Creates a [`WorkspacePaths`] implementation for the provided workspace
32    /// root.
33    fn workspace_paths_for(&self, workspace_root: &Path) -> Box<dyn WorkspacePaths>;
34
35    /// Returns the fallback configuration locations searched outside the
36    /// workspace.
37    fn home_config_paths(&self, config_file_name: &str) -> Vec<PathBuf>;
38
39    /// Returns the default syntax highlighting theme identifier.
40    fn syntax_theme(&self) -> String;
41
42    /// Returns the default list of syntax highlighting languages.
43    fn syntax_languages(&self) -> Vec<String>;
44}
45
46#[derive(Debug, Default)]
47struct DefaultConfigDefaults;
48
49impl ConfigDefaultsProvider for DefaultConfigDefaults {
50    fn workspace_paths_for(&self, workspace_root: &Path) -> Box<dyn WorkspacePaths> {
51        Box::new(DefaultWorkspacePaths::new(workspace_root.to_path_buf()))
52    }
53
54    fn home_config_paths(&self, config_file_name: &str) -> Vec<PathBuf> {
55        default_home_paths(config_file_name)
56    }
57
58    fn syntax_theme(&self) -> String {
59        DEFAULT_SYNTAX_THEME.to_string()
60    }
61
62    fn syntax_languages(&self) -> Vec<String> {
63        default_syntax_languages()
64    }
65}
66
67/// Installs a new [`ConfigDefaultsProvider`], returning the previous provider.
68pub fn install_config_defaults_provider(
69    provider: Arc<dyn ConfigDefaultsProvider>,
70) -> Arc<dyn ConfigDefaultsProvider> {
71    let mut guard = CONFIG_DEFAULTS.write().unwrap_or_else(|poisoned| {
72        tracing::warn!(
73            "config defaults provider lock poisoned while installing provider; recovering"
74        );
75        poisoned.into_inner()
76    });
77    std::mem::replace(&mut *guard, provider)
78}
79
80/// Restores the built-in defaults provider.
81pub fn reset_to_default_config_defaults() {
82    let _ = install_config_defaults_provider(Arc::new(DefaultConfigDefaults));
83}
84
85/// Executes the provided function with the currently installed provider.
86pub fn with_config_defaults<F, R>(operation: F) -> R
87where
88    F: FnOnce(&dyn ConfigDefaultsProvider) -> R,
89{
90    let guard = CONFIG_DEFAULTS.read().unwrap_or_else(|poisoned| {
91        tracing::warn!("config defaults provider lock poisoned while reading provider; recovering");
92        poisoned.into_inner()
93    });
94    operation(guard.as_ref())
95}
96
97/// Returns the currently installed provider as an [`Arc`].
98pub fn current_config_defaults() -> Arc<dyn ConfigDefaultsProvider> {
99    let guard = CONFIG_DEFAULTS.read().unwrap_or_else(|poisoned| {
100        tracing::warn!("config defaults provider lock poisoned while cloning provider; recovering");
101        poisoned.into_inner()
102    });
103    Arc::clone(&*guard)
104}
105
106pub fn with_config_defaults_provider_for_test<F, R>(
107    provider: Arc<dyn ConfigDefaultsProvider>,
108    action: F,
109) -> R
110where
111    F: FnOnce() -> R,
112{
113    use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
114
115    let previous = install_config_defaults_provider(provider);
116    let result = catch_unwind(AssertUnwindSafe(action));
117    let _ = install_config_defaults_provider(previous);
118
119    match result {
120        Ok(value) => value,
121        Err(payload) => resume_unwind(payload),
122    }
123}
124
125/// Get the XDG-compliant configuration directory for vtcode.
126///
127/// Follows the Ratatui recipe pattern for config directories:
128/// 1. Check environment variable VTCODE_CONFIG for custom location
129/// 2. Use XDG Base Directory Specification via ProjectDirs
130/// 3. Fallback to legacy ~/.vtcode/ for backwards compatibility
131///
132/// Returns `None` if no suitable directory can be determined.
133pub fn get_config_dir() -> Option<PathBuf> {
134    // Allow custom config directory via environment variable
135    if let Some(custom_dir) = read_env_var("VTCODE_CONFIG") {
136        let trimmed = custom_dir.trim();
137        if !trimmed.is_empty() {
138            return Some(PathBuf::from(trimmed));
139        }
140    }
141
142    // Use XDG-compliant directories (e.g., ~/.config/vtcode on Linux)
143    if let Some(proj_dirs) = ProjectDirs::from("com", "vinhnx", "vtcode") {
144        return Some(proj_dirs.config_local_dir().to_path_buf());
145    }
146
147    // Fallback to legacy ~/.vtcode/ for backwards compatibility
148    dirs::home_dir().map(|home| home.join(DEFAULT_CONFIG_DIR_NAME))
149}
150
151/// Get the XDG-compliant data directory for vtcode.
152///
153/// Follows the Ratatui recipe pattern for data directories:
154/// 1. Check environment variable VTCODE_DATA for custom location
155/// 2. Use XDG Base Directory Specification via ProjectDirs
156/// 3. Fallback to legacy ~/.vtcode/cache for backwards compatibility
157///
158/// Returns `None` if no suitable directory can be determined.
159pub fn get_data_dir() -> Option<PathBuf> {
160    // Allow custom data directory via environment variable
161    if let Some(custom_dir) = read_env_var("VTCODE_DATA") {
162        let trimmed = custom_dir.trim();
163        if !trimmed.is_empty() {
164            return Some(PathBuf::from(trimmed));
165        }
166    }
167
168    // Use XDG-compliant directories (e.g., ~/.local/share/vtcode on Linux)
169    if let Some(proj_dirs) = ProjectDirs::from("com", "vinhnx", "vtcode") {
170        return Some(proj_dirs.data_local_dir().to_path_buf());
171    }
172
173    // Fallback to legacy ~/.vtcode/cache for backwards compatibility
174    dirs::home_dir().map(|home| home.join(DEFAULT_CONFIG_DIR_NAME).join("cache"))
175}
176
177fn default_home_paths(config_file_name: &str) -> Vec<PathBuf> {
178    get_config_dir()
179        .map(|config_dir| config_dir.join(config_file_name))
180        .into_iter()
181        .collect()
182}
183
184fn default_syntax_languages() -> Vec<String> {
185    DEFAULT_SYNTAX_LANGUAGES.clone()
186}
187
188#[derive(Debug, Clone)]
189struct DefaultWorkspacePaths {
190    root: PathBuf,
191}
192
193impl DefaultWorkspacePaths {
194    fn new(root: PathBuf) -> Self {
195        Self { root }
196    }
197
198    fn config_dir_path(&self) -> PathBuf {
199        self.root.join(DEFAULT_CONFIG_DIR_NAME)
200    }
201}
202
203impl WorkspacePaths for DefaultWorkspacePaths {
204    fn workspace_root(&self) -> &Path {
205        &self.root
206    }
207
208    fn config_dir(&self) -> PathBuf {
209        self.config_dir_path()
210    }
211
212    fn cache_dir(&self) -> Option<PathBuf> {
213        Some(self.config_dir_path().join("cache"))
214    }
215
216    fn telemetry_dir(&self) -> Option<PathBuf> {
217        Some(self.config_dir_path().join("telemetry"))
218    }
219}
220
221/// Adapter that maps an existing [`WorkspacePaths`] implementation into a
222/// [`ConfigDefaultsProvider`].
223#[derive(Debug, Clone)]
224pub struct WorkspacePathsDefaults<P>
225where
226    P: WorkspacePaths + ?Sized,
227{
228    paths: Arc<P>,
229    config_file_name: String,
230    home_paths: Option<Vec<PathBuf>>,
231    syntax_theme: String,
232    syntax_languages: Vec<String>,
233}
234
235impl<P> WorkspacePathsDefaults<P>
236where
237    P: WorkspacePaths + 'static,
238{
239    /// Creates a defaults provider that delegates to the supplied
240    /// [`WorkspacePaths`] implementation.
241    pub fn new(paths: Arc<P>) -> Self {
242        Self {
243            paths,
244            config_file_name: DEFAULT_CONFIG_FILE_NAME.to_string(),
245            home_paths: None,
246            syntax_theme: DEFAULT_SYNTAX_THEME.to_string(),
247            syntax_languages: default_syntax_languages(),
248        }
249    }
250
251    /// Overrides the configuration file name returned by the provider.
252    pub fn with_config_file_name(mut self, file_name: impl Into<String>) -> Self {
253        self.config_file_name = file_name.into();
254        self
255    }
256
257    /// Overrides the fallback configuration search paths returned by the provider.
258    pub fn with_home_paths(mut self, home_paths: Vec<PathBuf>) -> Self {
259        self.home_paths = Some(home_paths);
260        self
261    }
262
263    /// Overrides the default syntax theme returned by the provider.
264    pub fn with_syntax_theme(mut self, theme: impl Into<String>) -> Self {
265        self.syntax_theme = theme.into();
266        self
267    }
268
269    /// Overrides the default syntax languages returned by the provider.
270    pub fn with_syntax_languages(mut self, languages: Vec<String>) -> Self {
271        self.syntax_languages = languages;
272        self
273    }
274
275    /// Consumes the builder, returning a boxed provider implementation.
276    pub fn build(self) -> Box<dyn ConfigDefaultsProvider> {
277        Box::new(self)
278    }
279}
280
281impl<P> ConfigDefaultsProvider for WorkspacePathsDefaults<P>
282where
283    P: WorkspacePaths + 'static,
284{
285    fn config_file_name(&self) -> &str {
286        &self.config_file_name
287    }
288
289    fn workspace_paths_for(&self, _workspace_root: &Path) -> Box<dyn WorkspacePaths> {
290        Box::new(WorkspacePathsWrapper {
291            inner: Arc::clone(&self.paths),
292        })
293    }
294
295    fn home_config_paths(&self, config_file_name: &str) -> Vec<PathBuf> {
296        self.home_paths
297            .clone()
298            .unwrap_or_else(|| default_home_paths(config_file_name))
299    }
300
301    fn syntax_theme(&self) -> String {
302        self.syntax_theme.clone()
303    }
304
305    fn syntax_languages(&self) -> Vec<String> {
306        self.syntax_languages.clone()
307    }
308}
309
310#[derive(Debug, Clone)]
311struct WorkspacePathsWrapper<P>
312where
313    P: WorkspacePaths + ?Sized,
314{
315    inner: Arc<P>,
316}
317
318impl<P> WorkspacePaths for WorkspacePathsWrapper<P>
319where
320    P: WorkspacePaths + ?Sized,
321{
322    fn workspace_root(&self) -> &Path {
323        self.inner.workspace_root()
324    }
325
326    fn config_dir(&self) -> PathBuf {
327        self.inner.config_dir()
328    }
329
330    fn cache_dir(&self) -> Option<PathBuf> {
331        self.inner.cache_dir()
332    }
333
334    fn telemetry_dir(&self) -> Option<PathBuf> {
335        self.inner.telemetry_dir()
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::{get_config_dir, get_data_dir};
342    use serial_test::serial;
343    use std::path::PathBuf;
344
345    fn with_env_var<F>(key: &str, value: Option<&str>, f: F)
346    where
347        F: FnOnce(),
348    {
349        let previous = crate::env_helpers::test_env_overrides::get(key);
350        crate::env_helpers::test_env_overrides::set(key, value);
351
352        f();
353
354        crate::env_helpers::test_env_overrides::restore(key, previous);
355    }
356
357    #[test]
358    #[serial]
359    fn get_config_dir_uses_env_override() {
360        with_env_var("VTCODE_CONFIG", Some("/tmp/vtcode-config-test"), || {
361            assert_eq!(
362                get_config_dir(),
363                Some(PathBuf::from("/tmp/vtcode-config-test"))
364            );
365        });
366    }
367
368    #[test]
369    #[serial]
370    fn get_data_dir_uses_env_override() {
371        with_env_var("VTCODE_DATA", Some("/tmp/vtcode-data-test"), || {
372            assert_eq!(get_data_dir(), Some(PathBuf::from("/tmp/vtcode-data-test")));
373        });
374    }
375
376    #[test]
377    #[serial]
378    fn get_config_dir_ignores_blank_env_override() {
379        with_env_var("VTCODE_CONFIG", Some("   "), || {
380            let resolved = get_config_dir();
381            assert!(resolved.is_some());
382            assert_ne!(resolved, Some(PathBuf::from("   ")));
383            assert_ne!(resolved, Some(PathBuf::new()));
384        });
385    }
386
387    #[test]
388    #[serial]
389    fn get_data_dir_ignores_blank_env_override() {
390        with_env_var("VTCODE_DATA", Some("   "), || {
391            let resolved = get_data_dir();
392            assert!(resolved.is_some());
393            assert_ne!(resolved, Some(PathBuf::from("   ")));
394            assert_ne!(resolved, Some(PathBuf::new()));
395        });
396    }
397
398    #[test]
399    #[serial]
400    fn env_guard_restores_original_value() {
401        let key = "VTCODE_CONFIG";
402        let initial = super::read_env_var(key);
403        with_env_var(key, Some("/tmp/vtcode-config-test"), || {
404            assert_eq!(
405                super::read_env_var(key),
406                Some("/tmp/vtcode-config-test".to_string())
407            );
408        });
409        assert_eq!(super::read_env_var(key), initial);
410    }
411}