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#[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 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 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 #[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 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 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 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 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 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_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 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 #[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 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 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 pub fn config(&self) -> &VTCodeConfig {
289 &self.config
290 }
291
292 pub fn config_path(&self) -> Option<&Path> {
294 self.config_path.as_deref()
295 }
296
297 pub fn workspace_root(&self) -> Option<&Path> {
299 self.workspace_root.as_deref()
300 }
301
302 pub fn config_file_name(&self) -> &str {
304 &self.config_file_name
305 }
306
307 pub fn layer_stack(&self) -> &ConfigLayerStack {
309 &self.layer_stack
310 }
311
312 pub fn effective_config(&self) -> toml::Value {
314 self.layer_stack.effective_config()
315 }
316
317 pub fn session_duration(&self) -> std::time::Duration {
319 std::time::Duration::from_secs(60 * 60) }
321
322 pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
324 let path = path.as_ref();
325
326 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 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 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 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 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 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 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 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 pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
470 self.config = config.clone();
471 Ok(())
472 }
473}
474
475fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
484 let storage_mode = config.agent.credential_storage_mode;
485
486 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 let migration_results =
498 migrate_custom_api_keys_to_keyring(&config.agent.custom_api_keys, storage_mode)?;
499
500 let mut migrated_count = 0;
502 for (provider, success) in migration_results {
503 if success {
504 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}