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