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#[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 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 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 #[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 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 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 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 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 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_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 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 #[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 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 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 pub fn config(&self) -> &VTCodeConfig {
307 &self.config
308 }
309
310 pub fn config_path(&self) -> Option<&Path> {
312 self.config_path.as_deref()
313 }
314
315 pub fn workspace_root(&self) -> Option<&Path> {
317 self.workspace_root.as_deref()
318 }
319
320 pub fn config_file_name(&self) -> &str {
322 &self.config_file_name
323 }
324
325 pub fn layer_stack(&self) -> &ConfigLayerStack {
327 &self.layer_stack
328 }
329
330 pub fn effective_config(&self) -> toml::Value {
332 self.layer_stack.effective_config()
333 }
334
335 pub fn session_duration(&self) -> std::time::Duration {
337 std::time::Duration::from_secs(60 * 60) }
339
340 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 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 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 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 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 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 pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
602 self.config = config.clone();
603 Ok(())
604 }
605}
606
607fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
616 let storage_mode = config.agent.credential_storage_mode;
617
618 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 let migration_results =
630 migrate_custom_api_keys_to_keyring(&config.agent.custom_api_keys, storage_mode)?;
631
632 let mut migrated_count = 0;
634 for (provider, success) in migration_results {
635 if success {
636 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}