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