Skip to main content

vtcode_config/loader/
manager.rs

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