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 mut config = VTCodeConfig::default();
108 config.apply_compat_defaults();
109 config
110 .validate()
111 .context("Default configuration failed validation")?;
112
113 return Ok(Self {
114 config,
115 config_path: None,
116 workspace_root: Some(workspace_root),
117 config_file_name,
118 layer_stack,
119 });
120 }
121
122 if let Some((layer, error)) = layer_stack.first_layer_error() {
123 bail!(
124 "Configuration layer '{}' failed to load: {}",
125 layer.source.label(),
126 error.message
127 );
128 }
129
130 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
131 let mut config: VTCodeConfig = effective_toml
132 .try_into()
133 .context("Failed to deserialize effective configuration")?;
134 config.apply_compat_defaults();
135 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
136
137 config
138 .validate()
139 .context("Configuration failed validation")?;
140
141 migrate_custom_api_keys_if_needed(&mut config)?;
143
144 let config_path = layer_stack
145 .layers()
146 .iter()
147 .rev()
148 .find(|layer| layer.is_enabled())
149 .and_then(|l| match &l.source {
150 ConfigLayerSource::User { file } => Some(file.clone()),
151 ConfigLayerSource::Project { file } => Some(file.clone()),
152 ConfigLayerSource::Workspace { file } => Some(file.clone()),
153 ConfigLayerSource::System { file } => Some(file.clone()),
154 ConfigLayerSource::Runtime => None,
155 });
156
157 Ok(Self {
158 config,
159 config_path,
160 workspace_root: Some(workspace_root),
161 config_file_name,
162 layer_stack,
163 })
164 }
165
166 fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
167 let content = fs::read_to_string(path)
168 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
169 let value: toml::Value = toml::from_str(&content)
170 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
171 Ok(value)
172 }
173
174 fn load_optional_layer(source: ConfigLayerSource) -> Option<ConfigLayerEntry> {
175 let file = match &source {
176 ConfigLayerSource::System { file }
177 | ConfigLayerSource::User { file }
178 | ConfigLayerSource::Project { file }
179 | ConfigLayerSource::Workspace { file } => file,
180 ConfigLayerSource::Runtime => {
181 return Some(ConfigLayerEntry::new(
182 source,
183 toml::Value::Table(toml::Table::new()),
184 ));
185 }
186 };
187
188 if !file.exists() {
189 return None;
190 }
191
192 let resolved_file = canonicalize_workspace_root(file);
193 let resolved_source = match source {
194 ConfigLayerSource::System { .. } => ConfigLayerSource::System {
195 file: resolved_file.clone(),
196 },
197 ConfigLayerSource::User { .. } => ConfigLayerSource::User {
198 file: resolved_file.clone(),
199 },
200 ConfigLayerSource::Project { .. } => ConfigLayerSource::Project {
201 file: resolved_file.clone(),
202 },
203 ConfigLayerSource::Workspace { .. } => ConfigLayerSource::Workspace {
204 file: resolved_file.clone(),
205 },
206 ConfigLayerSource::Runtime => unreachable!(),
207 };
208
209 match Self::load_toml_from_file(&resolved_file) {
210 Ok(toml) => Some(ConfigLayerEntry::new(resolved_source, toml)),
211 Err(error) => Some(Self::disabled_layer_from_error(resolved_source, error)),
212 }
213 }
214
215 fn disabled_layer_from_error(
216 source: ConfigLayerSource,
217 error: anyhow::Error,
218 ) -> ConfigLayerEntry {
219 let reason = if error.to_string().contains("parse") {
220 LayerDisabledReason::ParseError
221 } else {
222 LayerDisabledReason::LoadError
223 };
224 ConfigLayerEntry::disabled(source, reason, format!("{:#}", error))
225 }
226
227 pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
229 let path = path.as_ref();
230 let defaults_provider = defaults::current_config_defaults();
231 let config_file_name = path
232 .file_name()
233 .and_then(|name| name.to_str().map(ToOwned::to_owned))
234 .unwrap_or_else(|| defaults_provider.config_file_name().to_string());
235
236 let mut layer_stack = ConfigLayerStack::default();
237
238 #[cfg(unix)]
240 {
241 let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
242 if let Some(layer) = Self::load_optional_layer(ConfigLayerSource::System {
243 file: system_config,
244 }) {
245 layer_stack.push(layer);
246 }
247 }
248
249 for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
251 if let Some(layer) = Self::load_optional_layer(ConfigLayerSource::User {
252 file: home_config_path,
253 }) {
254 layer_stack.push(layer);
255 }
256 }
257
258 match Self::load_toml_from_file(path) {
260 Ok(toml) => layer_stack.push(ConfigLayerEntry::new(
261 ConfigLayerSource::Workspace {
262 file: path.to_path_buf(),
263 },
264 toml,
265 )),
266 Err(error) => layer_stack.push(Self::disabled_layer_from_error(
267 ConfigLayerSource::Workspace {
268 file: path.to_path_buf(),
269 },
270 error,
271 )),
272 }
273
274 if let Some((layer, error)) = layer_stack.first_layer_error() {
275 bail!(
276 "Configuration layer '{}' failed to load: {}",
277 layer.source.label(),
278 error.message
279 );
280 }
281
282 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
283 let mut config: VTCodeConfig = effective_toml.try_into().with_context(|| {
284 format!(
285 "Failed to parse effective config with file: {}",
286 path.display()
287 )
288 })?;
289 config.apply_compat_defaults();
290 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
291
292 config.validate().with_context(|| {
293 format!(
294 "Failed to validate effective config with file: {}",
295 path.display()
296 )
297 })?;
298
299 Ok(Self {
300 config,
301 config_path: Some(canonicalize_workspace_root(path)),
302 workspace_root: path.parent().map(canonicalize_workspace_root),
303 config_file_name,
304 layer_stack,
305 })
306 }
307
308 pub fn config(&self) -> &VTCodeConfig {
310 &self.config
311 }
312
313 pub fn config_path(&self) -> Option<&Path> {
315 self.config_path.as_deref()
316 }
317
318 pub fn workspace_root(&self) -> Option<&Path> {
320 self.workspace_root.as_deref()
321 }
322
323 pub fn config_file_name(&self) -> &str {
325 &self.config_file_name
326 }
327
328 pub fn layer_stack(&self) -> &ConfigLayerStack {
330 &self.layer_stack
331 }
332
333 pub fn effective_config(&self) -> toml::Value {
335 self.layer_stack.effective_config()
336 }
337
338 pub fn session_duration(&self) -> std::time::Duration {
340 std::time::Duration::from_secs(60 * 60) }
342
343 pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
345 let path = path.as_ref();
346
347 if path.exists() {
349 let original_content = fs::read_to_string(path)
350 .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
351
352 let mut doc = original_content
353 .parse::<toml_edit::DocumentMut>()
354 .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
355
356 let new_value =
358 toml::to_string_pretty(config).context("Failed to serialize configuration")?;
359 let new_doc: toml_edit::DocumentMut = new_value
360 .parse()
361 .context("Failed to parse serialized configuration")?;
362
363 Self::merge_toml_documents(&mut doc, &new_doc);
365
366 fs::write(path, doc.to_string())
367 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
368 } else {
369 let content =
371 toml::to_string_pretty(config).context("Failed to serialize configuration")?;
372 fs::write(path, content)
373 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
374 }
375
376 Ok(())
377 }
378
379 fn merge_toml_documents(original: &mut toml_edit::DocumentMut, new: &toml_edit::DocumentMut) {
381 for (key, new_value) in new.iter() {
382 if let Some(original_value) = original.get_mut(key) {
383 Self::merge_toml_items(original_value, new_value);
384 } else {
385 original[key] = new_value.clone();
386 }
387 }
388 }
389
390 fn merge_toml_items(original: &mut toml_edit::Item, new: &toml_edit::Item) {
392 match (original, new) {
393 (toml_edit::Item::Table(orig_table), toml_edit::Item::Table(new_table)) => {
394 for (key, new_value) in new_table.iter() {
395 if let Some(orig_value) = orig_table.get_mut(key) {
396 Self::merge_toml_items(orig_value, new_value);
397 } else {
398 orig_table[key] = new_value.clone();
399 }
400 }
401 }
402 (orig, new) => {
403 *orig = new.clone();
404 }
405 }
406 }
407
408 fn project_config_path(
409 config_dir: &Path,
410 workspace_root: &Path,
411 config_file_name: &str,
412 ) -> Option<PathBuf> {
413 let project_name = Self::identify_current_project(workspace_root)?;
414 let project_config_path = config_dir
415 .join("projects")
416 .join(project_name)
417 .join("config")
418 .join(config_file_name);
419
420 if project_config_path.exists() {
421 Some(project_config_path)
422 } else {
423 None
424 }
425 }
426
427 fn identify_current_project(workspace_root: &Path) -> Option<String> {
428 let project_file = workspace_root.join(".vtcode-project");
429 if let Ok(contents) = fs::read_to_string(&project_file) {
430 let name = contents.trim();
431 if !name.is_empty() {
432 return Some(name.to_string());
433 }
434 }
435
436 workspace_root
437 .file_name()
438 .and_then(|name| name.to_str())
439 .map(|name| name.to_string())
440 }
441
442 pub fn current_project_name(workspace_root: &Path) -> Option<String> {
444 Self::identify_current_project(workspace_root)
445 }
446
447 fn validate_restricted_agent_fields(
448 layer_stack: &ConfigLayerStack,
449 origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
450 ) -> Result<()> {
451 if let Some(origin) = origins.get("agent.persistent_memory.directory_override")
452 && let Some(layer) = layer_stack
453 .layers()
454 .iter()
455 .find(|layer| layer.metadata == *origin)
456 {
457 match layer.source {
458 ConfigLayerSource::System { .. }
459 | ConfigLayerSource::User { .. }
460 | ConfigLayerSource::Project { .. } => {}
461 ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Runtime => {
462 bail!(
463 "agent.persistent_memory.directory_override may only be set in system, user, or project-profile configuration layers"
464 );
465 }
466 }
467 }
468
469 Ok(())
470 }
471
472 pub fn save_config(&mut self, config: &VTCodeConfig) -> Result<()> {
474 if let Some(path) = &self.config_path {
475 Self::save_config_to_path(path, config)?;
476 } else if let Some(workspace_root) = &self.workspace_root {
477 let path = workspace_root.join(&self.config_file_name);
478 Self::save_config_to_path(path, config)?;
479 } else {
480 let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
481 let path = cwd.join(&self.config_file_name);
482 Self::save_config_to_path(path, config)?;
483 }
484
485 self.sync_from_config(config)
486 }
487
488 pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
491 self.config = config.clone();
492 Ok(())
493 }
494}
495
496fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
505 let storage_mode = config.agent.credential_storage_mode;
506
507 let has_plain_text_keys = config
509 .agent
510 .custom_api_keys
511 .values()
512 .any(|key| !key.is_empty());
513
514 if has_plain_text_keys {
515 tracing::info!("Detected plain-text API keys in config, migrating to secure storage...");
516
517 let migration_results =
519 migrate_custom_api_keys_to_keyring(&config.agent.custom_api_keys, storage_mode)?;
520
521 let mut migrated_count = 0;
523 for (provider, success) in migration_results {
524 if success {
525 config.agent.custom_api_keys.insert(provider, String::new());
527 migrated_count += 1;
528 }
529 }
530
531 if migrated_count > 0 {
532 tracing::info!(
533 "Successfully migrated {} API key(s) to secure storage",
534 migrated_count
535 );
536 tracing::warn!(
537 "Plain-text API keys have been cleared from config file. \
538 Please commit the updated config to remove sensitive data from version control."
539 );
540 }
541 }
542
543 Ok(())
544}