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#[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 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_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 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 #[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 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 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 pub fn config(&self) -> &VTCodeConfig {
284 &self.config
285 }
286
287 pub fn config_path(&self) -> Option<&Path> {
289 self.config_path.as_deref()
290 }
291
292 pub fn workspace_root(&self) -> Option<&Path> {
294 self.workspace_root.as_deref()
295 }
296
297 pub fn config_file_name(&self) -> &str {
299 &self.config_file_name
300 }
301
302 pub fn layer_stack(&self) -> &ConfigLayerStack {
304 &self.layer_stack
305 }
306
307 pub fn effective_config(&self) -> toml::Value {
309 self.layer_stack.effective_config()
310 }
311
312 pub fn session_duration(&self) -> std::time::Duration {
314 std::time::Duration::from_secs(60 * 60) }
316
317 pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
319 let path = path.as_ref();
320
321 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 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 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 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 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 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 pub fn current_project_name(workspace_root: &Path) -> Option<String> {
418 Self::identify_current_project(workspace_root)
419 }
420
421 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 pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
440 self.config = config.clone();
441 Ok(())
442 }
443}
444
445fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
454 let storage_mode = config.agent.credential_storage_mode;
455
456 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 let migration_results =
468 migrate_custom_api_keys_to_keyring(&config.agent.custom_api_keys, storage_mode)?;
469
470 let mut migrated_count = 0;
472 for (provider, success) in migration_results {
473 if success {
474 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}