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 if let Ok(workspace_path) = std::env::var("VTCODE_WORKSPACE") {
39 let trimmed = workspace_path.trim();
40 if !trimmed.is_empty() {
41 return Self::load_from_workspace(trimmed).with_context(|| {
42 format!(
43 "Failed to load configuration from VTCODE_WORKSPACE={}",
44 trimmed
45 )
46 });
47 }
48 }
49
50 Self::load_from_workspace(std::env::current_dir()?)
51 }
52
53 pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
55 let workspace = workspace.as_ref();
56 let defaults_provider = defaults::current_config_defaults();
57 let workspace_paths = defaults_provider.workspace_paths_for(workspace);
58 let workspace_root = workspace_paths.workspace_root().to_path_buf();
59 let config_dir = workspace_paths.config_dir();
60 let config_file_name = defaults_provider.config_file_name().to_string();
61
62 let mut layer_stack = ConfigLayerStack::default();
63
64 #[cfg(unix)]
66 {
67 let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
68 if let Some(layer) = Self::load_optional_layer(ConfigLayerSource::System {
69 file: system_config,
70 }) {
71 layer_stack.push(layer);
72 }
73 }
74
75 for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
77 if let Some(layer) = Self::load_optional_layer(ConfigLayerSource::User {
78 file: home_config_path,
79 }) {
80 layer_stack.push(layer);
81 }
82 }
83
84 if let Some(project_config_path) =
86 Self::project_config_path(&config_dir, &workspace_root, &config_file_name)
87 && let Some(layer) = Self::load_optional_layer(ConfigLayerSource::Project {
88 file: project_config_path,
89 })
90 {
91 layer_stack.push(layer);
92 }
93
94 let fallback_path = config_dir.join(&config_file_name);
96 let workspace_config_path = workspace_root.join(&config_file_name);
97 if fallback_path.exists()
98 && fallback_path != workspace_config_path
99 && let Some(layer) = Self::load_optional_layer(ConfigLayerSource::Workspace {
100 file: fallback_path,
101 })
102 {
103 layer_stack.push(layer);
104 }
105
106 if let Some(layer) = Self::load_optional_layer(ConfigLayerSource::Workspace {
108 file: workspace_config_path.clone(),
109 }) {
110 layer_stack.push(layer);
111 }
112
113 if layer_stack.layers().is_empty() {
115 let config = VTCodeConfig::default();
116 config
117 .validate()
118 .context("Default configuration failed validation")?;
119
120 return Ok(Self {
121 config,
122 config_path: None,
123 workspace_root: Some(workspace_root),
124 config_file_name,
125 layer_stack,
126 });
127 }
128
129 if let Some((layer, error)) = layer_stack.first_layer_error() {
130 bail!(
131 "Configuration layer '{}' failed to load: {}",
132 layer.source.label(),
133 error.message
134 );
135 }
136
137 let effective_toml = layer_stack.effective_config_without_origins();
138 let mut config: VTCodeConfig = effective_toml
139 .try_into()
140 .context("Failed to deserialize effective configuration")?;
141
142 config
143 .validate()
144 .context("Configuration failed validation")?;
145
146 migrate_custom_api_keys_if_needed(&mut config)?;
148
149 let config_path = layer_stack
150 .layers()
151 .iter()
152 .rev()
153 .find(|layer| layer.is_enabled())
154 .and_then(|l| match &l.source {
155 ConfigLayerSource::User { file } => Some(file.clone()),
156 ConfigLayerSource::Project { file } => Some(file.clone()),
157 ConfigLayerSource::Workspace { file } => Some(file.clone()),
158 ConfigLayerSource::System { file } => Some(file.clone()),
159 ConfigLayerSource::Runtime => None,
160 });
161
162 Ok(Self {
163 config,
164 config_path,
165 workspace_root: Some(workspace_root),
166 config_file_name,
167 layer_stack,
168 })
169 }
170
171 fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
172 let content = fs::read_to_string(path)
173 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
174 let value: toml::Value = toml::from_str(&content)
175 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
176 Ok(value)
177 }
178
179 fn load_optional_layer(source: ConfigLayerSource) -> Option<ConfigLayerEntry> {
180 let file = match &source {
181 ConfigLayerSource::System { file }
182 | ConfigLayerSource::User { file }
183 | ConfigLayerSource::Project { file }
184 | ConfigLayerSource::Workspace { file } => file,
185 ConfigLayerSource::Runtime => {
186 return Some(ConfigLayerEntry::new(
187 source,
188 toml::Value::Table(toml::Table::new()),
189 ));
190 }
191 };
192
193 if !file.exists() {
194 return None;
195 }
196
197 match Self::load_toml_from_file(file) {
198 Ok(toml) => Some(ConfigLayerEntry::new(source, toml)),
199 Err(error) => Some(Self::disabled_layer_from_error(source, error)),
200 }
201 }
202
203 fn disabled_layer_from_error(
204 source: ConfigLayerSource,
205 error: anyhow::Error,
206 ) -> ConfigLayerEntry {
207 let reason = if error.to_string().contains("parse") {
208 LayerDisabledReason::ParseError
209 } else {
210 LayerDisabledReason::LoadError
211 };
212 ConfigLayerEntry::disabled(source, reason, format!("{:#}", error))
213 }
214
215 pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
217 let path = path.as_ref();
218 let defaults_provider = defaults::current_config_defaults();
219 let config_file_name = path
220 .file_name()
221 .and_then(|name| name.to_str().map(ToOwned::to_owned))
222 .unwrap_or_else(|| defaults_provider.config_file_name().to_string());
223
224 let mut layer_stack = ConfigLayerStack::default();
225
226 #[cfg(unix)]
228 {
229 let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
230 if let Some(layer) = Self::load_optional_layer(ConfigLayerSource::System {
231 file: system_config,
232 }) {
233 layer_stack.push(layer);
234 }
235 }
236
237 for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
239 if let Some(layer) = Self::load_optional_layer(ConfigLayerSource::User {
240 file: home_config_path,
241 }) {
242 layer_stack.push(layer);
243 }
244 }
245
246 match Self::load_toml_from_file(path) {
248 Ok(toml) => layer_stack.push(ConfigLayerEntry::new(
249 ConfigLayerSource::Workspace {
250 file: path.to_path_buf(),
251 },
252 toml,
253 )),
254 Err(error) => layer_stack.push(Self::disabled_layer_from_error(
255 ConfigLayerSource::Workspace {
256 file: path.to_path_buf(),
257 },
258 error,
259 )),
260 }
261
262 if let Some((layer, error)) = layer_stack.first_layer_error() {
263 bail!(
264 "Configuration layer '{}' failed to load: {}",
265 layer.source.label(),
266 error.message
267 );
268 }
269
270 let effective_toml = layer_stack.effective_config_without_origins();
271 let config: VTCodeConfig = effective_toml.try_into().with_context(|| {
272 format!(
273 "Failed to parse effective config with file: {}",
274 path.display()
275 )
276 })?;
277
278 config.validate().with_context(|| {
279 format!(
280 "Failed to validate effective config with file: {}",
281 path.display()
282 )
283 })?;
284
285 Ok(Self {
286 config,
287 config_path: Some(path.to_path_buf()),
288 workspace_root: path.parent().map(Path::to_path_buf),
289 config_file_name,
290 layer_stack,
291 })
292 }
293
294 pub fn config(&self) -> &VTCodeConfig {
296 &self.config
297 }
298
299 pub fn config_path(&self) -> Option<&Path> {
301 self.config_path.as_deref()
302 }
303
304 pub fn workspace_root(&self) -> Option<&Path> {
306 self.workspace_root.as_deref()
307 }
308
309 pub fn config_file_name(&self) -> &str {
311 &self.config_file_name
312 }
313
314 pub fn layer_stack(&self) -> &ConfigLayerStack {
316 &self.layer_stack
317 }
318
319 pub fn effective_config(&self) -> toml::Value {
321 self.layer_stack.effective_config()
322 }
323
324 pub fn session_duration(&self) -> std::time::Duration {
326 std::time::Duration::from_secs(60 * 60) }
328
329 pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
331 let path = path.as_ref();
332
333 if path.exists() {
335 let original_content = fs::read_to_string(path)
336 .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
337
338 let mut doc = original_content
339 .parse::<toml_edit::DocumentMut>()
340 .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
341
342 let new_value =
344 toml::to_string_pretty(config).context("Failed to serialize configuration")?;
345 let new_doc: toml_edit::DocumentMut = new_value
346 .parse()
347 .context("Failed to parse serialized configuration")?;
348
349 Self::merge_toml_documents(&mut doc, &new_doc);
351
352 fs::write(path, doc.to_string())
353 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
354 } else {
355 let content =
357 toml::to_string_pretty(config).context("Failed to serialize configuration")?;
358 fs::write(path, content)
359 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
360 }
361
362 Ok(())
363 }
364
365 fn merge_toml_documents(original: &mut toml_edit::DocumentMut, new: &toml_edit::DocumentMut) {
367 for (key, new_value) in new.iter() {
368 if let Some(original_value) = original.get_mut(key) {
369 Self::merge_toml_items(original_value, new_value);
370 } else {
371 original[key] = new_value.clone();
372 }
373 }
374 }
375
376 fn merge_toml_items(original: &mut toml_edit::Item, new: &toml_edit::Item) {
378 match (original, new) {
379 (toml_edit::Item::Table(orig_table), toml_edit::Item::Table(new_table)) => {
380 for (key, new_value) in new_table.iter() {
381 if let Some(orig_value) = orig_table.get_mut(key) {
382 Self::merge_toml_items(orig_value, new_value);
383 } else {
384 orig_table[key] = new_value.clone();
385 }
386 }
387 }
388 (orig, new) => {
389 *orig = new.clone();
390 }
391 }
392 }
393
394 fn project_config_path(
395 config_dir: &Path,
396 workspace_root: &Path,
397 config_file_name: &str,
398 ) -> Option<PathBuf> {
399 let project_name = Self::identify_current_project(workspace_root)?;
400 let project_config_path = config_dir
401 .join("projects")
402 .join(project_name)
403 .join("config")
404 .join(config_file_name);
405
406 if project_config_path.exists() {
407 Some(project_config_path)
408 } else {
409 None
410 }
411 }
412
413 fn identify_current_project(workspace_root: &Path) -> Option<String> {
414 let project_file = workspace_root.join(".vtcode-project");
415 if let Ok(contents) = fs::read_to_string(&project_file) {
416 let name = contents.trim();
417 if !name.is_empty() {
418 return Some(name.to_string());
419 }
420 }
421
422 workspace_root
423 .file_name()
424 .and_then(|name| name.to_str())
425 .map(|name| name.to_string())
426 }
427
428 pub fn current_project_name(workspace_root: &Path) -> Option<String> {
430 Self::identify_current_project(workspace_root)
431 }
432
433 pub fn save_config(&mut self, config: &VTCodeConfig) -> Result<()> {
435 if let Some(path) = &self.config_path {
436 Self::save_config_to_path(path, config)?;
437 } else if let Some(workspace_root) = &self.workspace_root {
438 let path = workspace_root.join(&self.config_file_name);
439 Self::save_config_to_path(path, config)?;
440 } else {
441 let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
442 let path = cwd.join(&self.config_file_name);
443 Self::save_config_to_path(path, config)?;
444 }
445
446 self.sync_from_config(config)
447 }
448
449 pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
452 self.config = config.clone();
453 Ok(())
454 }
455}
456
457fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
466 let storage_mode = config.agent.credential_storage_mode;
467
468 let has_plain_text_keys = config
470 .agent
471 .custom_api_keys
472 .values()
473 .any(|key| !key.is_empty());
474
475 if has_plain_text_keys {
476 tracing::info!("Detected plain-text API keys in config, migrating to secure storage...");
477
478 let migration_results =
480 migrate_custom_api_keys_to_keyring(&config.agent.custom_api_keys, storage_mode)?;
481
482 let mut migrated_count = 0;
484 for (provider, success) in migration_results {
485 if success {
486 config.agent.custom_api_keys.insert(provider, String::new());
488 migrated_count += 1;
489 }
490 }
491
492 if migrated_count > 0 {
493 tracing::info!(
494 "Successfully migrated {} API key(s) to secure storage",
495 migrated_count
496 );
497 tracing::warn!(
498 "Plain-text API keys have been cleared from config file. \
499 Please commit the updated config to remove sensitive data from version control."
500 );
501 }
502 }
503
504 Ok(())
505}