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 config = VTCodeConfig::default();
108            config
109                .validate()
110                .context("Default configuration failed validation")?;
111
112            return Ok(Self {
113                config,
114                config_path: None,
115                workspace_root: Some(workspace_root),
116                config_file_name,
117                layer_stack,
118            });
119        }
120
121        if let Some((layer, error)) = layer_stack.first_layer_error() {
122            bail!(
123                "Configuration layer '{}' failed to load: {}",
124                layer.source.label(),
125                error.message
126            );
127        }
128
129        let (effective_toml, origins) = layer_stack.effective_config_with_origins();
130        let mut config: VTCodeConfig = effective_toml
131            .try_into()
132            .context("Failed to deserialize effective configuration")?;
133        Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
134
135        config
136            .validate()
137            .context("Configuration failed validation")?;
138
139        // Migrate any plain-text API keys from config to secure storage
140        migrate_custom_api_keys_if_needed(&mut config)?;
141
142        let config_path = layer_stack
143            .layers()
144            .iter()
145            .rev()
146            .find(|layer| layer.is_enabled())
147            .and_then(|l| match &l.source {
148                ConfigLayerSource::User { file } => Some(file.clone()),
149                ConfigLayerSource::Project { file } => Some(file.clone()),
150                ConfigLayerSource::Workspace { file } => Some(file.clone()),
151                ConfigLayerSource::System { file } => Some(file.clone()),
152                ConfigLayerSource::Runtime => None,
153            });
154
155        Ok(Self {
156            config,
157            config_path,
158            workspace_root: Some(workspace_root),
159            config_file_name,
160            layer_stack,
161        })
162    }
163
164    fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
165        let content = fs::read_to_string(path)
166            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
167        let value: toml::Value = toml::from_str(&content)
168            .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
169        Ok(value)
170    }
171
172    fn load_optional_layer(source: ConfigLayerSource) -> Option<ConfigLayerEntry> {
173        let file = match &source {
174            ConfigLayerSource::System { file }
175            | ConfigLayerSource::User { file }
176            | ConfigLayerSource::Project { file }
177            | ConfigLayerSource::Workspace { file } => file,
178            ConfigLayerSource::Runtime => {
179                return Some(ConfigLayerEntry::new(
180                    source,
181                    toml::Value::Table(toml::Table::new()),
182                ));
183            }
184        };
185
186        if !file.exists() {
187            return None;
188        }
189
190        let resolved_file = canonicalize_workspace_root(file);
191        let resolved_source = match source {
192            ConfigLayerSource::System { .. } => ConfigLayerSource::System {
193                file: resolved_file.clone(),
194            },
195            ConfigLayerSource::User { .. } => ConfigLayerSource::User {
196                file: resolved_file.clone(),
197            },
198            ConfigLayerSource::Project { .. } => ConfigLayerSource::Project {
199                file: resolved_file.clone(),
200            },
201            ConfigLayerSource::Workspace { .. } => ConfigLayerSource::Workspace {
202                file: resolved_file.clone(),
203            },
204            ConfigLayerSource::Runtime => unreachable!(),
205        };
206
207        match Self::load_toml_from_file(&resolved_file) {
208            Ok(toml) => Some(ConfigLayerEntry::new(resolved_source, toml)),
209            Err(error) => Some(Self::disabled_layer_from_error(resolved_source, error)),
210        }
211    }
212
213    fn disabled_layer_from_error(
214        source: ConfigLayerSource,
215        error: anyhow::Error,
216    ) -> ConfigLayerEntry {
217        let reason = if error.to_string().contains("parse") {
218            LayerDisabledReason::ParseError
219        } else {
220            LayerDisabledReason::LoadError
221        };
222        ConfigLayerEntry::disabled(source, reason, format!("{:#}", error))
223    }
224
225    /// Load configuration from a specific file
226    pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
227        let path = path.as_ref();
228        let defaults_provider = defaults::current_config_defaults();
229        let config_file_name = path
230            .file_name()
231            .and_then(|name| name.to_str().map(ToOwned::to_owned))
232            .unwrap_or_else(|| defaults_provider.config_file_name().to_string());
233
234        let mut layer_stack = ConfigLayerStack::default();
235
236        // 1. System config
237        #[cfg(unix)]
238        {
239            let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
240            if let Some(layer) = Self::load_optional_layer(ConfigLayerSource::System {
241                file: system_config,
242            }) {
243                layer_stack.push(layer);
244            }
245        }
246
247        // 2. User home config
248        for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
249            if let Some(layer) = Self::load_optional_layer(ConfigLayerSource::User {
250                file: home_config_path,
251            }) {
252                layer_stack.push(layer);
253            }
254        }
255
256        // 3. The specific file provided (Workspace layer)
257        match Self::load_toml_from_file(path) {
258            Ok(toml) => layer_stack.push(ConfigLayerEntry::new(
259                ConfigLayerSource::Workspace {
260                    file: path.to_path_buf(),
261                },
262                toml,
263            )),
264            Err(error) => layer_stack.push(Self::disabled_layer_from_error(
265                ConfigLayerSource::Workspace {
266                    file: path.to_path_buf(),
267                },
268                error,
269            )),
270        }
271
272        if let Some((layer, error)) = layer_stack.first_layer_error() {
273            bail!(
274                "Configuration layer '{}' failed to load: {}",
275                layer.source.label(),
276                error.message
277            );
278        }
279
280        let (effective_toml, origins) = layer_stack.effective_config_with_origins();
281        let config: VTCodeConfig = effective_toml.try_into().with_context(|| {
282            format!(
283                "Failed to parse effective config with file: {}",
284                path.display()
285            )
286        })?;
287        Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
288
289        config.validate().with_context(|| {
290            format!(
291                "Failed to validate effective config with file: {}",
292                path.display()
293            )
294        })?;
295
296        Ok(Self {
297            config,
298            config_path: Some(canonicalize_workspace_root(path)),
299            workspace_root: path.parent().map(canonicalize_workspace_root),
300            config_file_name,
301            layer_stack,
302        })
303    }
304
305    /// Get the loaded configuration
306    pub fn config(&self) -> &VTCodeConfig {
307        &self.config
308    }
309
310    /// Get the configuration file path (if loaded from file)
311    pub fn config_path(&self) -> Option<&Path> {
312        self.config_path.as_deref()
313    }
314
315    /// Get the active workspace root for this manager.
316    pub fn workspace_root(&self) -> Option<&Path> {
317        self.workspace_root.as_deref()
318    }
319
320    /// Get the config filename used by this manager (usually `vtcode.toml`).
321    pub fn config_file_name(&self) -> &str {
322        &self.config_file_name
323    }
324
325    /// Get the configuration layer stack
326    pub fn layer_stack(&self) -> &ConfigLayerStack {
327        &self.layer_stack
328    }
329
330    /// Get the effective TOML configuration
331    pub fn effective_config(&self) -> toml::Value {
332        self.layer_stack.effective_config()
333    }
334
335    /// Get session duration from agent config
336    pub fn session_duration(&self) -> std::time::Duration {
337        std::time::Duration::from_secs(60 * 60) // Default 1 hour
338    }
339
340    /// Persist configuration to a specific path, preserving comments
341    pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
342        let path = path.as_ref();
343        let sparse_value =
344            Self::sparse_config_value(config).context("Failed to prepare sparse configuration")?;
345        let sparse_content = toml::to_string_pretty(&sparse_value)
346            .context("Failed to serialize sparse configuration")?;
347
348        // If file exists, preserve comments by using toml_edit
349        if path.exists() {
350            let original_content = fs::read_to_string(path)
351                .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
352
353            let mut doc = original_content
354                .parse::<toml_edit::DocumentMut>()
355                .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
356            Self::remove_deprecated_config_keys(&mut doc);
357
358            let new_doc: toml_edit::DocumentMut = sparse_content
359                .parse()
360                .context("Failed to parse sparse serialized configuration")?;
361            let default_value = toml::Value::try_from(VTCodeConfig::default())
362                .context("Failed to serialize default configuration")?;
363            let default_doc: toml_edit::DocumentMut = toml::to_string_pretty(&default_value)
364                .context("Failed to serialize default configuration")?
365                .parse()
366                .context("Failed to parse default serialized configuration")?;
367
368            // Update values while preserving structure and comments
369            Self::merge_sparse_toml_documents(&mut doc, &new_doc, &default_doc);
370
371            fs::write(path, doc.to_string())
372                .with_context(|| format!("Failed to write config file: {}", path.display()))?;
373        } else {
374            fs::write(path, sparse_content)
375                .with_context(|| format!("Failed to write config file: {}", path.display()))?;
376        }
377
378        Ok(())
379    }
380
381    fn remove_deprecated_config_keys(doc: &mut toml_edit::DocumentMut) {
382        let table = doc.as_table_mut();
383        table.remove("project_doc_max_bytes");
384        table.remove("project_doc_fallback_filenames");
385        Self::remove_table_keys(table, "agent", &["autonomous_mode", "default_editing_mode"]);
386        Self::remove_table_keys(table, "permissions", &["allowed_tools", "disallowed_tools"]);
387    }
388
389    fn remove_table_keys(table: &mut toml_edit::Table, section: &str, keys: &[&str]) {
390        let Some(section) = table
391            .get_mut(section)
392            .and_then(toml_edit::Item::as_table_mut)
393        else {
394            return;
395        };
396
397        for key in keys {
398            section.remove(key);
399        }
400    }
401
402    pub fn sparse_config_value(config: &VTCodeConfig) -> Result<toml::Value> {
403        let mut value =
404            toml::Value::try_from(config).context("Failed to serialize configuration")?;
405        let default_value = toml::Value::try_from(VTCodeConfig::default())
406            .context("Failed to serialize default configuration")?;
407        Self::prune_default_values(&mut value, &default_value);
408        Ok(value)
409    }
410
411    fn prune_default_values(value: &mut toml::Value, default_value: &toml::Value) -> bool {
412        match (value, default_value) {
413            (toml::Value::Table(table), toml::Value::Table(default_table)) => {
414                table.retain(|key, child| {
415                    default_table.get(key).is_none_or(|default_child| {
416                        !Self::prune_default_values(child, default_child)
417                    })
418                });
419                table.is_empty()
420            }
421            (value, default_value) => value == default_value,
422        }
423    }
424
425    /// Merge TOML documents, preserving comments and structure from original
426    fn merge_sparse_toml_documents(
427        original: &mut toml_edit::DocumentMut,
428        new: &toml_edit::DocumentMut,
429        default_doc: &toml_edit::DocumentMut,
430    ) {
431        Self::merge_sparse_tables(
432            original.as_table_mut(),
433            new.as_table(),
434            default_doc.as_table(),
435        );
436    }
437
438    fn merge_sparse_tables(
439        original: &mut toml_edit::Table,
440        new: &toml_edit::Table,
441        default_table: &toml_edit::Table,
442    ) {
443        let mut remove_keys = Vec::new();
444
445        for (key, default_value) in default_table.iter() {
446            if let Some(new_value) = new.get(key) {
447                if let Some(original_value) = original.get_mut(key) {
448                    Self::merge_sparse_items(original_value, new_value, default_value);
449                } else {
450                    original[key] = new_value.clone();
451                }
452            } else {
453                let Some(original_value) = original.get_mut(key) else {
454                    continue;
455                };
456                if Self::remove_known_default_item(original_value, default_value) {
457                    remove_keys.push(key.to_string());
458                }
459            }
460        }
461
462        for key in remove_keys {
463            original.remove(&key);
464        }
465
466        for (key, new_value) in new.iter() {
467            if default_table.contains_key(key) {
468                continue;
469            }
470            if let Some(original_value) = original.get_mut(key) {
471                *original_value = new_value.clone();
472            } else {
473                original[key] = new_value.clone();
474            }
475        }
476    }
477
478    fn merge_sparse_items(
479        original: &mut toml_edit::Item,
480        new: &toml_edit::Item,
481        default_value: &toml_edit::Item,
482    ) {
483        match (original, new, default_value) {
484            (
485                toml_edit::Item::Table(orig_table),
486                toml_edit::Item::Table(new_table),
487                toml_edit::Item::Table(default_table),
488            ) => Self::merge_sparse_tables(orig_table, new_table, default_table),
489            (orig, new, _) => {
490                *orig = new.clone();
491            }
492        }
493    }
494
495    fn remove_known_default_item(
496        original: &mut toml_edit::Item,
497        default_value: &toml_edit::Item,
498    ) -> bool {
499        match (original, default_value) {
500            (toml_edit::Item::Table(orig_table), toml_edit::Item::Table(default_table)) => {
501                let mut remove_keys = Vec::new();
502                for (key, default_child) in default_table.iter() {
503                    let Some(orig_child) = orig_table.get_mut(key) else {
504                        continue;
505                    };
506                    if Self::remove_known_default_item(orig_child, default_child) {
507                        remove_keys.push(key.to_string());
508                    }
509                }
510                for key in remove_keys {
511                    orig_table.remove(&key);
512                }
513                orig_table.is_empty()
514            }
515            _ => true,
516        }
517    }
518
519    fn project_config_path(
520        config_dir: &Path,
521        workspace_root: &Path,
522        config_file_name: &str,
523    ) -> Option<PathBuf> {
524        let project_name = Self::identify_current_project(workspace_root)?;
525        let project_config_path = config_dir
526            .join("projects")
527            .join(project_name)
528            .join("config")
529            .join(config_file_name);
530
531        if project_config_path.exists() {
532            Some(project_config_path)
533        } else {
534            None
535        }
536    }
537
538    fn identify_current_project(workspace_root: &Path) -> Option<String> {
539        let project_file = workspace_root.join(".vtcode-project");
540        if let Ok(contents) = fs::read_to_string(&project_file) {
541            let name = contents.trim();
542            if !name.is_empty() {
543                return Some(name.to_string());
544            }
545        }
546
547        workspace_root
548            .file_name()
549            .and_then(|name| name.to_str())
550            .map(|name| name.to_string())
551    }
552
553    /// Resolve the current project name used for project-level config overlays.
554    pub fn current_project_name(workspace_root: &Path) -> Option<String> {
555        Self::identify_current_project(workspace_root)
556    }
557
558    fn validate_restricted_agent_fields(
559        layer_stack: &ConfigLayerStack,
560        origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
561    ) -> Result<()> {
562        if let Some(origin) = origins.get("agent.persistent_memory.directory_override")
563            && let Some(layer) = layer_stack
564                .layers()
565                .iter()
566                .find(|layer| layer.metadata == *origin)
567        {
568            match layer.source {
569                ConfigLayerSource::System { .. }
570                | ConfigLayerSource::User { .. }
571                | ConfigLayerSource::Project { .. } => {}
572                ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Runtime => {
573                    bail!(
574                        "agent.persistent_memory.directory_override may only be set in system, user, or project-profile configuration layers"
575                    );
576                }
577            }
578        }
579
580        Ok(())
581    }
582
583    /// Persist configuration to the manager's associated path or workspace
584    pub fn save_config(&mut self, config: &VTCodeConfig) -> Result<()> {
585        if let Some(path) = &self.config_path {
586            Self::save_config_to_path(path, config)?;
587        } else if let Some(workspace_root) = &self.workspace_root {
588            let path = workspace_root.join(&self.config_file_name);
589            Self::save_config_to_path(path, config)?;
590        } else {
591            let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
592            let path = cwd.join(&self.config_file_name);
593            Self::save_config_to_path(path, config)?;
594        }
595
596        self.sync_from_config(config)
597    }
598
599    /// Sync internal config from a saved config
600    /// Call this after save_config to keep internal state in sync
601    pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
602        self.config = config.clone();
603        Ok(())
604    }
605}
606
607/// Migrate plain-text API keys from config to secure storage.
608///
609/// This function checks if there are any API keys stored in plain-text in the config
610/// and migrates them to secure storage (keyring). After successful migration, the
611/// keys are cleared from the config (kept as empty strings for tracking).
612///
613/// # Arguments
614/// * `config` - The configuration to migrate
615fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
616    let storage_mode = config.agent.credential_storage_mode;
617
618    // Check if there are any non-empty API keys in the config
619    let has_plain_text_keys = config
620        .agent
621        .custom_api_keys
622        .values()
623        .any(|key| !key.is_empty());
624
625    if has_plain_text_keys {
626        tracing::info!("Detected plain-text API keys in config, migrating to secure storage...");
627
628        // Migrate keys to secure storage
629        let migration_results =
630            migrate_custom_api_keys_to_keyring(&config.agent.custom_api_keys, storage_mode)?;
631
632        // Clear keys from config (keep provider names for tracking)
633        let mut migrated_count = 0;
634        for (provider, success) in migration_results {
635            if success {
636                // Replace with empty string to track that this provider has a stored key
637                config.agent.custom_api_keys.insert(provider, String::new());
638                migrated_count += 1;
639            }
640        }
641
642        if migrated_count > 0 {
643            tracing::info!(
644                "Successfully migrated {} API key(s) to secure storage",
645                migrated_count
646            );
647            tracing::warn!(
648                "Plain-text API keys have been cleared from config file. \
649                 Please commit the updated config to remove sensitive data from version control."
650            );
651        }
652    }
653
654    Ok(())
655}