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