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