Skip to main content

vtcode_config/loader/
manager.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5
6use crate::auth::migrate_custom_api_keys_to_keyring;
7use crate::defaults::{self};
8use crate::loader::config::VTCodeConfig;
9use crate::loader::layers::{ConfigLayerEntry, ConfigLayerSource, ConfigLayerStack};
10
11/// Configuration manager for loading and validating configurations
12#[derive(Clone)]
13pub struct ConfigManager {
14    pub(crate) config: VTCodeConfig,
15    config_path: Option<PathBuf>,
16    workspace_root: Option<PathBuf>,
17    config_file_name: String,
18    pub(crate) layer_stack: ConfigLayerStack,
19}
20
21impl ConfigManager {
22    /// Load configuration from the default locations
23    pub fn load() -> Result<Self> {
24        if let Ok(config_path) = std::env::var("VTCODE_CONFIG_PATH") {
25            let trimmed = config_path.trim();
26            if !trimmed.is_empty() {
27                return Self::load_from_file(trimmed).with_context(|| {
28                    format!(
29                        "Failed to load configuration from VTCODE_CONFIG_PATH={}",
30                        trimmed
31                    )
32                });
33            }
34        }
35
36        if let Ok(workspace_path) = std::env::var("VTCODE_WORKSPACE") {
37            let trimmed = workspace_path.trim();
38            if !trimmed.is_empty() {
39                return Self::load_from_workspace(trimmed).with_context(|| {
40                    format!(
41                        "Failed to load configuration from VTCODE_WORKSPACE={}",
42                        trimmed
43                    )
44                });
45            }
46        }
47
48        Self::load_from_workspace(std::env::current_dir()?)
49    }
50
51    /// Load configuration from a specific workspace
52    pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
53        let workspace = workspace.as_ref();
54        let defaults_provider = defaults::current_config_defaults();
55        let workspace_paths = defaults_provider.workspace_paths_for(workspace);
56        let workspace_root = workspace_paths.workspace_root().to_path_buf();
57        let config_dir = workspace_paths.config_dir();
58        let config_file_name = defaults_provider.config_file_name().to_string();
59
60        let mut layer_stack = ConfigLayerStack::default();
61
62        // 1. System config (e.g., /etc/vtcode/vtcode.toml)
63        #[cfg(unix)]
64        {
65            let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
66            if system_config.exists()
67                && let Ok(toml) = Self::load_toml_from_file(&system_config)
68            {
69                layer_stack.push(ConfigLayerEntry::new(
70                    ConfigLayerSource::System {
71                        file: system_config,
72                    },
73                    toml,
74                ));
75            }
76        }
77
78        // 2. User home config (~/.vtcode/vtcode.toml)
79        for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
80            if home_config_path.exists()
81                && let Ok(toml) = Self::load_toml_from_file(&home_config_path)
82            {
83                layer_stack.push(ConfigLayerEntry::new(
84                    ConfigLayerSource::User {
85                        file: home_config_path,
86                    },
87                    toml,
88                ));
89            }
90        }
91
92        // 2. Project-specific config (.vtcode/projects/<project>/config/vtcode.toml)
93        if let Some(project_config_path) =
94            Self::project_config_path(&config_dir, &workspace_root, &config_file_name)
95            && let Ok(toml) = Self::load_toml_from_file(&project_config_path)
96        {
97            layer_stack.push(ConfigLayerEntry::new(
98                ConfigLayerSource::Project {
99                    file: project_config_path,
100                },
101                toml,
102            ));
103        }
104
105        // 3. Config directory fallback (.vtcode/vtcode.toml)
106        let fallback_path = config_dir.join(&config_file_name);
107        let workspace_config_path = workspace_root.join(&config_file_name);
108        if fallback_path.exists()
109            && fallback_path != workspace_config_path
110            && let Ok(toml) = Self::load_toml_from_file(&fallback_path)
111        {
112            layer_stack.push(ConfigLayerEntry::new(
113                ConfigLayerSource::Workspace {
114                    file: fallback_path,
115                },
116                toml,
117            ));
118        }
119
120        // 4. Workspace config (vtcode.toml in workspace root)
121        if workspace_config_path.exists()
122            && let Ok(toml) = Self::load_toml_from_file(&workspace_config_path)
123        {
124            layer_stack.push(ConfigLayerEntry::new(
125                ConfigLayerSource::Workspace {
126                    file: workspace_config_path.clone(),
127                },
128                toml,
129            ));
130        }
131
132        // If no layers found, use default config
133        if layer_stack.layers().is_empty() {
134            let config = VTCodeConfig::default();
135            config
136                .validate()
137                .context("Default configuration failed validation")?;
138
139            return Ok(Self {
140                config,
141                config_path: None,
142                workspace_root: Some(workspace_root),
143                config_file_name,
144                layer_stack,
145            });
146        }
147
148        let effective_toml = layer_stack.effective_config();
149        let mut config: VTCodeConfig = effective_toml
150            .try_into()
151            .context("Failed to deserialize effective configuration")?;
152
153        config
154            .validate()
155            .context("Configuration failed validation")?;
156
157        // Migrate any plain-text API keys from config to secure storage
158        migrate_custom_api_keys_if_needed(&mut config)?;
159
160        let config_path = layer_stack.layers().last().and_then(|l| match &l.source {
161            ConfigLayerSource::User { file } => Some(file.clone()),
162            ConfigLayerSource::Project { file } => Some(file.clone()),
163            ConfigLayerSource::Workspace { file } => Some(file.clone()),
164            ConfigLayerSource::System { file } => Some(file.clone()),
165            ConfigLayerSource::Runtime => None,
166        });
167
168        Ok(Self {
169            config,
170            config_path,
171            workspace_root: Some(workspace_root),
172            config_file_name,
173            layer_stack,
174        })
175    }
176
177    fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
178        let content = fs::read_to_string(path)
179            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
180        let value: toml::Value = toml::from_str(&content)
181            .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
182        Ok(value)
183    }
184
185    /// Load configuration from a specific file
186    pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
187        let path = path.as_ref();
188        let defaults_provider = defaults::current_config_defaults();
189        let config_file_name = path
190            .file_name()
191            .and_then(|name| name.to_str().map(ToOwned::to_owned))
192            .unwrap_or_else(|| defaults_provider.config_file_name().to_string());
193
194        let mut layer_stack = ConfigLayerStack::default();
195
196        // 1. System config
197        #[cfg(unix)]
198        {
199            let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
200            if system_config.exists()
201                && let Ok(toml) = Self::load_toml_from_file(&system_config)
202            {
203                layer_stack.push(ConfigLayerEntry::new(
204                    ConfigLayerSource::System {
205                        file: system_config,
206                    },
207                    toml,
208                ));
209            }
210        }
211
212        // 2. User home config
213        for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
214            if home_config_path.exists()
215                && let Ok(toml) = Self::load_toml_from_file(&home_config_path)
216            {
217                layer_stack.push(ConfigLayerEntry::new(
218                    ConfigLayerSource::User {
219                        file: home_config_path,
220                    },
221                    toml,
222                ));
223            }
224        }
225
226        // 3. The specific file provided (Workspace layer)
227        let toml = Self::load_toml_from_file(path)?;
228        layer_stack.push(ConfigLayerEntry::new(
229            ConfigLayerSource::Workspace {
230                file: path.to_path_buf(),
231            },
232            toml,
233        ));
234
235        let effective_toml = layer_stack.effective_config();
236        let config: VTCodeConfig = effective_toml.try_into().with_context(|| {
237            format!(
238                "Failed to parse effective config with file: {}",
239                path.display()
240            )
241        })?;
242
243        config.validate().with_context(|| {
244            format!(
245                "Failed to validate effective config with file: {}",
246                path.display()
247            )
248        })?;
249
250        Ok(Self {
251            config,
252            config_path: Some(path.to_path_buf()),
253            workspace_root: path.parent().map(Path::to_path_buf),
254            config_file_name,
255            layer_stack,
256        })
257    }
258
259    /// Get the loaded configuration
260    pub fn config(&self) -> &VTCodeConfig {
261        &self.config
262    }
263
264    /// Get the configuration file path (if loaded from file)
265    pub fn config_path(&self) -> Option<&Path> {
266        self.config_path.as_deref()
267    }
268
269    /// Get the configuration layer stack
270    pub fn layer_stack(&self) -> &ConfigLayerStack {
271        &self.layer_stack
272    }
273
274    /// Get the effective TOML configuration
275    pub fn effective_config(&self) -> toml::Value {
276        self.layer_stack.effective_config()
277    }
278
279    /// Get session duration from agent config
280    pub fn session_duration(&self) -> std::time::Duration {
281        std::time::Duration::from_secs(60 * 60) // Default 1 hour
282    }
283
284    /// Persist configuration to a specific path, preserving comments
285    pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
286        let path = path.as_ref();
287
288        // If file exists, preserve comments by using toml_edit
289        if path.exists() {
290            let original_content = fs::read_to_string(path)
291                .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
292
293            let mut doc = original_content
294                .parse::<toml_edit::DocumentMut>()
295                .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
296
297            // Serialize new config to TOML value
298            let new_value =
299                toml::to_string_pretty(config).context("Failed to serialize configuration")?;
300            let new_doc: toml_edit::DocumentMut = new_value
301                .parse()
302                .context("Failed to parse serialized configuration")?;
303
304            // Update values while preserving structure and comments
305            Self::merge_toml_documents(&mut doc, &new_doc);
306
307            fs::write(path, doc.to_string())
308                .with_context(|| format!("Failed to write config file: {}", path.display()))?;
309        } else {
310            // New file, just write normally
311            let content =
312                toml::to_string_pretty(config).context("Failed to serialize configuration")?;
313            fs::write(path, content)
314                .with_context(|| format!("Failed to write config file: {}", path.display()))?;
315        }
316
317        Ok(())
318    }
319
320    /// Merge TOML documents, preserving comments and structure from original
321    fn merge_toml_documents(original: &mut toml_edit::DocumentMut, new: &toml_edit::DocumentMut) {
322        for (key, new_value) in new.iter() {
323            if let Some(original_value) = original.get_mut(key) {
324                Self::merge_toml_items(original_value, new_value);
325            } else {
326                original[key] = new_value.clone();
327            }
328        }
329    }
330
331    /// Recursively merge TOML items
332    fn merge_toml_items(original: &mut toml_edit::Item, new: &toml_edit::Item) {
333        match (original, new) {
334            (toml_edit::Item::Table(orig_table), toml_edit::Item::Table(new_table)) => {
335                for (key, new_value) in new_table.iter() {
336                    if let Some(orig_value) = orig_table.get_mut(key) {
337                        Self::merge_toml_items(orig_value, new_value);
338                    } else {
339                        orig_table[key] = new_value.clone();
340                    }
341                }
342            }
343            (orig, new) => {
344                *orig = new.clone();
345            }
346        }
347    }
348
349    fn project_config_path(
350        config_dir: &Path,
351        workspace_root: &Path,
352        config_file_name: &str,
353    ) -> Option<PathBuf> {
354        let project_name = Self::identify_current_project(workspace_root)?;
355        let project_config_path = config_dir
356            .join("projects")
357            .join(project_name)
358            .join("config")
359            .join(config_file_name);
360
361        if project_config_path.exists() {
362            Some(project_config_path)
363        } else {
364            None
365        }
366    }
367
368    fn identify_current_project(workspace_root: &Path) -> Option<String> {
369        let project_file = workspace_root.join(".vtcode-project");
370        if let Ok(contents) = fs::read_to_string(&project_file) {
371            let name = contents.trim();
372            if !name.is_empty() {
373                return Some(name.to_string());
374            }
375        }
376
377        workspace_root
378            .file_name()
379            .and_then(|name| name.to_str())
380            .map(|name| name.to_string())
381    }
382
383    /// Persist configuration to the manager's associated path or workspace
384    pub fn save_config(&mut self, config: &VTCodeConfig) -> Result<()> {
385        if let Some(path) = &self.config_path {
386            Self::save_config_to_path(path, config)?;
387        } else if let Some(workspace_root) = &self.workspace_root {
388            let path = workspace_root.join(&self.config_file_name);
389            Self::save_config_to_path(path, config)?;
390        } else {
391            let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
392            let path = cwd.join(&self.config_file_name);
393            Self::save_config_to_path(path, config)?;
394        }
395
396        self.sync_from_config(config)
397    }
398
399    /// Sync internal config from a saved config
400    /// Call this after save_config to keep internal state in sync
401    pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
402        self.config = config.clone();
403        Ok(())
404    }
405}
406
407/// Migrate plain-text API keys from config to secure storage.
408///
409/// This function checks if there are any API keys stored in plain-text in the config
410/// and migrates them to secure storage (keyring). After successful migration, the
411/// keys are cleared from the config (kept as empty strings for tracking).
412///
413/// # Arguments
414/// * `config` - The configuration to migrate
415fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
416    let storage_mode = config.agent.credential_storage_mode;
417    
418    // Check if there are any non-empty API keys in the config
419    let has_plain_text_keys = config
420        .agent
421        .custom_api_keys
422        .values()
423        .any(|key| !key.is_empty());
424    
425    if has_plain_text_keys {
426        tracing::info!(
427            "Detected plain-text API keys in config, migrating to secure storage..."
428        );
429        
430        // Migrate keys to secure storage
431        let migration_results = migrate_custom_api_keys_to_keyring(
432            &config.agent.custom_api_keys,
433            storage_mode,
434        )?;
435        
436        // Clear keys from config (keep provider names for tracking)
437        let mut migrated_count = 0;
438        for (provider, success) in migration_results {
439            if success {
440                // Replace with empty string to track that this provider has a stored key
441                config.agent.custom_api_keys.insert(provider, String::new());
442                migrated_count += 1;
443            }
444        }
445        
446        if migrated_count > 0 {
447            tracing::info!(
448                "Successfully migrated {} API key(s) to secure storage",
449                migrated_count
450            );
451            tracing::warn!(
452                "Plain-text API keys have been cleared from config file. \
453                 Please commit the updated config to remove sensitive data from version control."
454            );
455        }
456    }
457    
458    Ok(())
459}