1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5
6use crate::auth::migrate_custom_api_keys_to_keyring;
7use crate::defaults::{self};
8use crate::loader::config::VTCodeConfig;
9use crate::loader::layers::{ConfigLayerEntry, ConfigLayerSource, ConfigLayerStack};
10
11#[derive(Clone)]
13pub struct ConfigManager {
14 pub(crate) config: VTCodeConfig,
15 config_path: Option<PathBuf>,
16 workspace_root: Option<PathBuf>,
17 config_file_name: String,
18 pub(crate) layer_stack: ConfigLayerStack,
19}
20
21impl ConfigManager {
22 pub fn load() -> Result<Self> {
24 if let Ok(config_path) = std::env::var("VTCODE_CONFIG_PATH") {
25 let trimmed = config_path.trim();
26 if !trimmed.is_empty() {
27 return Self::load_from_file(trimmed).with_context(|| {
28 format!(
29 "Failed to load configuration from VTCODE_CONFIG_PATH={}",
30 trimmed
31 )
32 });
33 }
34 }
35
36 if let Ok(workspace_path) = std::env::var("VTCODE_WORKSPACE") {
37 let trimmed = workspace_path.trim();
38 if !trimmed.is_empty() {
39 return Self::load_from_workspace(trimmed).with_context(|| {
40 format!(
41 "Failed to load configuration from VTCODE_WORKSPACE={}",
42 trimmed
43 )
44 });
45 }
46 }
47
48 Self::load_from_workspace(std::env::current_dir()?)
49 }
50
51 pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
53 let workspace = workspace.as_ref();
54 let defaults_provider = defaults::current_config_defaults();
55 let workspace_paths = defaults_provider.workspace_paths_for(workspace);
56 let workspace_root = workspace_paths.workspace_root().to_path_buf();
57 let config_dir = workspace_paths.config_dir();
58 let config_file_name = defaults_provider.config_file_name().to_string();
59
60 let mut layer_stack = ConfigLayerStack::default();
61
62 #[cfg(unix)]
64 {
65 let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
66 if system_config.exists()
67 && let Ok(toml) = Self::load_toml_from_file(&system_config)
68 {
69 layer_stack.push(ConfigLayerEntry::new(
70 ConfigLayerSource::System {
71 file: system_config,
72 },
73 toml,
74 ));
75 }
76 }
77
78 for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
80 if home_config_path.exists()
81 && let Ok(toml) = Self::load_toml_from_file(&home_config_path)
82 {
83 layer_stack.push(ConfigLayerEntry::new(
84 ConfigLayerSource::User {
85 file: home_config_path,
86 },
87 toml,
88 ));
89 }
90 }
91
92 if let Some(project_config_path) =
94 Self::project_config_path(&config_dir, &workspace_root, &config_file_name)
95 && let Ok(toml) = Self::load_toml_from_file(&project_config_path)
96 {
97 layer_stack.push(ConfigLayerEntry::new(
98 ConfigLayerSource::Project {
99 file: project_config_path,
100 },
101 toml,
102 ));
103 }
104
105 let fallback_path = config_dir.join(&config_file_name);
107 let workspace_config_path = workspace_root.join(&config_file_name);
108 if fallback_path.exists()
109 && fallback_path != workspace_config_path
110 && let Ok(toml) = Self::load_toml_from_file(&fallback_path)
111 {
112 layer_stack.push(ConfigLayerEntry::new(
113 ConfigLayerSource::Workspace {
114 file: fallback_path,
115 },
116 toml,
117 ));
118 }
119
120 if workspace_config_path.exists()
122 && let Ok(toml) = Self::load_toml_from_file(&workspace_config_path)
123 {
124 layer_stack.push(ConfigLayerEntry::new(
125 ConfigLayerSource::Workspace {
126 file: workspace_config_path.clone(),
127 },
128 toml,
129 ));
130 }
131
132 if layer_stack.layers().is_empty() {
134 let config = VTCodeConfig::default();
135 config
136 .validate()
137 .context("Default configuration failed validation")?;
138
139 return Ok(Self {
140 config,
141 config_path: None,
142 workspace_root: Some(workspace_root),
143 config_file_name,
144 layer_stack,
145 });
146 }
147
148 let effective_toml = layer_stack.effective_config();
149 let mut config: VTCodeConfig = effective_toml
150 .try_into()
151 .context("Failed to deserialize effective configuration")?;
152
153 config
154 .validate()
155 .context("Configuration failed validation")?;
156
157 migrate_custom_api_keys_if_needed(&mut config)?;
159
160 let config_path = layer_stack.layers().last().and_then(|l| match &l.source {
161 ConfigLayerSource::User { file } => Some(file.clone()),
162 ConfigLayerSource::Project { file } => Some(file.clone()),
163 ConfigLayerSource::Workspace { file } => Some(file.clone()),
164 ConfigLayerSource::System { file } => Some(file.clone()),
165 ConfigLayerSource::Runtime => None,
166 });
167
168 Ok(Self {
169 config,
170 config_path,
171 workspace_root: Some(workspace_root),
172 config_file_name,
173 layer_stack,
174 })
175 }
176
177 fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
178 let content = fs::read_to_string(path)
179 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
180 let value: toml::Value = toml::from_str(&content)
181 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
182 Ok(value)
183 }
184
185 pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
187 let path = path.as_ref();
188 let defaults_provider = defaults::current_config_defaults();
189 let config_file_name = path
190 .file_name()
191 .and_then(|name| name.to_str().map(ToOwned::to_owned))
192 .unwrap_or_else(|| defaults_provider.config_file_name().to_string());
193
194 let mut layer_stack = ConfigLayerStack::default();
195
196 #[cfg(unix)]
198 {
199 let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
200 if system_config.exists()
201 && let Ok(toml) = Self::load_toml_from_file(&system_config)
202 {
203 layer_stack.push(ConfigLayerEntry::new(
204 ConfigLayerSource::System {
205 file: system_config,
206 },
207 toml,
208 ));
209 }
210 }
211
212 for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
214 if home_config_path.exists()
215 && let Ok(toml) = Self::load_toml_from_file(&home_config_path)
216 {
217 layer_stack.push(ConfigLayerEntry::new(
218 ConfigLayerSource::User {
219 file: home_config_path,
220 },
221 toml,
222 ));
223 }
224 }
225
226 let toml = Self::load_toml_from_file(path)?;
228 layer_stack.push(ConfigLayerEntry::new(
229 ConfigLayerSource::Workspace {
230 file: path.to_path_buf(),
231 },
232 toml,
233 ));
234
235 let effective_toml = layer_stack.effective_config();
236 let config: VTCodeConfig = effective_toml.try_into().with_context(|| {
237 format!(
238 "Failed to parse effective config with file: {}",
239 path.display()
240 )
241 })?;
242
243 config.validate().with_context(|| {
244 format!(
245 "Failed to validate effective config with file: {}",
246 path.display()
247 )
248 })?;
249
250 Ok(Self {
251 config,
252 config_path: Some(path.to_path_buf()),
253 workspace_root: path.parent().map(Path::to_path_buf),
254 config_file_name,
255 layer_stack,
256 })
257 }
258
259 pub fn config(&self) -> &VTCodeConfig {
261 &self.config
262 }
263
264 pub fn config_path(&self) -> Option<&Path> {
266 self.config_path.as_deref()
267 }
268
269 pub fn layer_stack(&self) -> &ConfigLayerStack {
271 &self.layer_stack
272 }
273
274 pub fn effective_config(&self) -> toml::Value {
276 self.layer_stack.effective_config()
277 }
278
279 pub fn session_duration(&self) -> std::time::Duration {
281 std::time::Duration::from_secs(60 * 60) }
283
284 pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
286 let path = path.as_ref();
287
288 if path.exists() {
290 let original_content = fs::read_to_string(path)
291 .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
292
293 let mut doc = original_content
294 .parse::<toml_edit::DocumentMut>()
295 .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
296
297 let new_value =
299 toml::to_string_pretty(config).context("Failed to serialize configuration")?;
300 let new_doc: toml_edit::DocumentMut = new_value
301 .parse()
302 .context("Failed to parse serialized configuration")?;
303
304 Self::merge_toml_documents(&mut doc, &new_doc);
306
307 fs::write(path, doc.to_string())
308 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
309 } else {
310 let content =
312 toml::to_string_pretty(config).context("Failed to serialize configuration")?;
313 fs::write(path, content)
314 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
315 }
316
317 Ok(())
318 }
319
320 fn merge_toml_documents(original: &mut toml_edit::DocumentMut, new: &toml_edit::DocumentMut) {
322 for (key, new_value) in new.iter() {
323 if let Some(original_value) = original.get_mut(key) {
324 Self::merge_toml_items(original_value, new_value);
325 } else {
326 original[key] = new_value.clone();
327 }
328 }
329 }
330
331 fn merge_toml_items(original: &mut toml_edit::Item, new: &toml_edit::Item) {
333 match (original, new) {
334 (toml_edit::Item::Table(orig_table), toml_edit::Item::Table(new_table)) => {
335 for (key, new_value) in new_table.iter() {
336 if let Some(orig_value) = orig_table.get_mut(key) {
337 Self::merge_toml_items(orig_value, new_value);
338 } else {
339 orig_table[key] = new_value.clone();
340 }
341 }
342 }
343 (orig, new) => {
344 *orig = new.clone();
345 }
346 }
347 }
348
349 fn project_config_path(
350 config_dir: &Path,
351 workspace_root: &Path,
352 config_file_name: &str,
353 ) -> Option<PathBuf> {
354 let project_name = Self::identify_current_project(workspace_root)?;
355 let project_config_path = config_dir
356 .join("projects")
357 .join(project_name)
358 .join("config")
359 .join(config_file_name);
360
361 if project_config_path.exists() {
362 Some(project_config_path)
363 } else {
364 None
365 }
366 }
367
368 fn identify_current_project(workspace_root: &Path) -> Option<String> {
369 let project_file = workspace_root.join(".vtcode-project");
370 if let Ok(contents) = fs::read_to_string(&project_file) {
371 let name = contents.trim();
372 if !name.is_empty() {
373 return Some(name.to_string());
374 }
375 }
376
377 workspace_root
378 .file_name()
379 .and_then(|name| name.to_str())
380 .map(|name| name.to_string())
381 }
382
383 pub fn save_config(&mut self, config: &VTCodeConfig) -> Result<()> {
385 if let Some(path) = &self.config_path {
386 Self::save_config_to_path(path, config)?;
387 } else if let Some(workspace_root) = &self.workspace_root {
388 let path = workspace_root.join(&self.config_file_name);
389 Self::save_config_to_path(path, config)?;
390 } else {
391 let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
392 let path = cwd.join(&self.config_file_name);
393 Self::save_config_to_path(path, config)?;
394 }
395
396 self.sync_from_config(config)
397 }
398
399 pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
402 self.config = config.clone();
403 Ok(())
404 }
405}
406
407fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
416 let storage_mode = config.agent.credential_storage_mode;
417
418 let has_plain_text_keys = config
420 .agent
421 .custom_api_keys
422 .values()
423 .any(|key| !key.is_empty());
424
425 if has_plain_text_keys {
426 tracing::info!(
427 "Detected plain-text API keys in config, migrating to secure storage..."
428 );
429
430 let migration_results = migrate_custom_api_keys_to_keyring(
432 &config.agent.custom_api_keys,
433 storage_mode,
434 )?;
435
436 let mut migrated_count = 0;
438 for (provider, success) in migration_results {
439 if success {
440 config.agent.custom_api_keys.insert(provider, String::new());
442 migrated_count += 1;
443 }
444 }
445
446 if migrated_count > 0 {
447 tracing::info!(
448 "Successfully migrated {} API key(s) to secure storage",
449 migrated_count
450 );
451 tracing::warn!(
452 "Plain-text API keys have been cleared from config file. \
453 Please commit the updated config to remove sensitive data from version control."
454 );
455 }
456 }
457
458 Ok(())
459}