1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5
6use crate::defaults::{self};
7use crate::loader::config::VTCodeConfig;
8use crate::loader::layers::{ConfigLayerEntry, ConfigLayerSource, ConfigLayerStack};
9
10#[derive(Clone)]
12pub struct ConfigManager {
13 pub(crate) config: VTCodeConfig,
14 config_path: Option<PathBuf>,
15 workspace_root: Option<PathBuf>,
16 config_file_name: String,
17 pub(crate) layer_stack: ConfigLayerStack,
18}
19
20impl ConfigManager {
21 pub fn load() -> Result<Self> {
23 if let Ok(config_path) = std::env::var("VTCODE_CONFIG_PATH") {
24 let trimmed = config_path.trim();
25 if !trimmed.is_empty() {
26 return Self::load_from_file(trimmed).with_context(|| {
27 format!(
28 "Failed to load configuration from VTCODE_CONFIG_PATH={}",
29 trimmed
30 )
31 });
32 }
33 }
34
35 if let Ok(workspace_path) = std::env::var("VTCODE_WORKSPACE") {
36 let trimmed = workspace_path.trim();
37 if !trimmed.is_empty() {
38 return Self::load_from_workspace(trimmed).with_context(|| {
39 format!(
40 "Failed to load configuration from VTCODE_WORKSPACE={}",
41 trimmed
42 )
43 });
44 }
45 }
46
47 Self::load_from_workspace(std::env::current_dir()?)
48 }
49
50 pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
52 let workspace = workspace.as_ref();
53 let defaults_provider = defaults::current_config_defaults();
54 let workspace_paths = defaults_provider.workspace_paths_for(workspace);
55 let workspace_root = workspace_paths.workspace_root().to_path_buf();
56 let config_dir = workspace_paths.config_dir();
57 let config_file_name = defaults_provider.config_file_name().to_string();
58
59 let mut layer_stack = ConfigLayerStack::default();
60
61 #[cfg(unix)]
63 {
64 let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
65 if system_config.exists()
66 && let Ok(toml) = Self::load_toml_from_file(&system_config)
67 {
68 layer_stack.push(ConfigLayerEntry::new(
69 ConfigLayerSource::System {
70 file: system_config,
71 },
72 toml,
73 ));
74 }
75 }
76
77 for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
79 if home_config_path.exists()
80 && let Ok(toml) = Self::load_toml_from_file(&home_config_path)
81 {
82 layer_stack.push(ConfigLayerEntry::new(
83 ConfigLayerSource::User {
84 file: home_config_path,
85 },
86 toml,
87 ));
88 }
89 }
90
91 if let Some(project_config_path) =
93 Self::project_config_path(&config_dir, &workspace_root, &config_file_name)
94 && let Ok(toml) = Self::load_toml_from_file(&project_config_path)
95 {
96 layer_stack.push(ConfigLayerEntry::new(
97 ConfigLayerSource::Project {
98 file: project_config_path,
99 },
100 toml,
101 ));
102 }
103
104 let fallback_path = config_dir.join(&config_file_name);
106 let workspace_config_path = workspace_root.join(&config_file_name);
107 if fallback_path.exists()
108 && fallback_path != workspace_config_path
109 && let Ok(toml) = Self::load_toml_from_file(&fallback_path)
110 {
111 layer_stack.push(ConfigLayerEntry::new(
112 ConfigLayerSource::Workspace {
113 file: fallback_path,
114 },
115 toml,
116 ));
117 }
118
119 if workspace_config_path.exists()
121 && let Ok(toml) = Self::load_toml_from_file(&workspace_config_path)
122 {
123 layer_stack.push(ConfigLayerEntry::new(
124 ConfigLayerSource::Workspace {
125 file: workspace_config_path.clone(),
126 },
127 toml,
128 ));
129 }
130
131 if layer_stack.layers().is_empty() {
133 let config = VTCodeConfig::default();
134 config
135 .validate()
136 .context("Default configuration failed validation")?;
137
138 return Ok(Self {
139 config,
140 config_path: None,
141 workspace_root: Some(workspace_root),
142 config_file_name,
143 layer_stack,
144 });
145 }
146
147 let effective_toml = layer_stack.effective_config();
148 let config: VTCodeConfig = effective_toml
149 .try_into()
150 .context("Failed to deserialize effective configuration")?;
151
152 config
153 .validate()
154 .context("Configuration failed validation")?;
155
156 let config_path = layer_stack.layers().last().and_then(|l| match &l.source {
157 ConfigLayerSource::User { file } => Some(file.clone()),
158 ConfigLayerSource::Project { file } => Some(file.clone()),
159 ConfigLayerSource::Workspace { file } => Some(file.clone()),
160 ConfigLayerSource::System { file } => Some(file.clone()),
161 ConfigLayerSource::Runtime => None,
162 });
163
164 Ok(Self {
165 config,
166 config_path,
167 workspace_root: Some(workspace_root),
168 config_file_name,
169 layer_stack,
170 })
171 }
172
173 fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
174 let content = fs::read_to_string(path)
175 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
176 let value: toml::Value = toml::from_str(&content)
177 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
178 Ok(value)
179 }
180
181 pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
183 let path = path.as_ref();
184 let defaults_provider = defaults::current_config_defaults();
185 let config_file_name = path
186 .file_name()
187 .and_then(|name| name.to_str().map(ToOwned::to_owned))
188 .unwrap_or_else(|| defaults_provider.config_file_name().to_string());
189
190 let mut layer_stack = ConfigLayerStack::default();
191
192 #[cfg(unix)]
194 {
195 let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
196 if system_config.exists()
197 && let Ok(toml) = Self::load_toml_from_file(&system_config)
198 {
199 layer_stack.push(ConfigLayerEntry::new(
200 ConfigLayerSource::System {
201 file: system_config,
202 },
203 toml,
204 ));
205 }
206 }
207
208 for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
210 if home_config_path.exists()
211 && let Ok(toml) = Self::load_toml_from_file(&home_config_path)
212 {
213 layer_stack.push(ConfigLayerEntry::new(
214 ConfigLayerSource::User {
215 file: home_config_path,
216 },
217 toml,
218 ));
219 }
220 }
221
222 let toml = Self::load_toml_from_file(path)?;
224 layer_stack.push(ConfigLayerEntry::new(
225 ConfigLayerSource::Workspace {
226 file: path.to_path_buf(),
227 },
228 toml,
229 ));
230
231 let effective_toml = layer_stack.effective_config();
232 let config: VTCodeConfig = effective_toml.try_into().with_context(|| {
233 format!(
234 "Failed to parse effective config with file: {}",
235 path.display()
236 )
237 })?;
238
239 config.validate().with_context(|| {
240 format!(
241 "Failed to validate effective config with file: {}",
242 path.display()
243 )
244 })?;
245
246 Ok(Self {
247 config,
248 config_path: Some(path.to_path_buf()),
249 workspace_root: path.parent().map(Path::to_path_buf),
250 config_file_name,
251 layer_stack,
252 })
253 }
254
255 pub fn config(&self) -> &VTCodeConfig {
257 &self.config
258 }
259
260 pub fn config_path(&self) -> Option<&Path> {
262 self.config_path.as_deref()
263 }
264
265 pub fn layer_stack(&self) -> &ConfigLayerStack {
267 &self.layer_stack
268 }
269
270 pub fn effective_config(&self) -> toml::Value {
272 self.layer_stack.effective_config()
273 }
274
275 pub fn session_duration(&self) -> std::time::Duration {
277 std::time::Duration::from_secs(60 * 60) }
279
280 pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
282 let path = path.as_ref();
283
284 if path.exists() {
286 let original_content = fs::read_to_string(path)
287 .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
288
289 let mut doc = original_content
290 .parse::<toml_edit::DocumentMut>()
291 .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
292
293 let new_value =
295 toml::to_string_pretty(config).context("Failed to serialize configuration")?;
296 let new_doc: toml_edit::DocumentMut = new_value
297 .parse()
298 .context("Failed to parse serialized configuration")?;
299
300 Self::merge_toml_documents(&mut doc, &new_doc);
302
303 fs::write(path, doc.to_string())
304 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
305 } else {
306 let content =
308 toml::to_string_pretty(config).context("Failed to serialize configuration")?;
309 fs::write(path, content)
310 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
311 }
312
313 Ok(())
314 }
315
316 fn merge_toml_documents(original: &mut toml_edit::DocumentMut, new: &toml_edit::DocumentMut) {
318 for (key, new_value) in new.iter() {
319 if let Some(original_value) = original.get_mut(key) {
320 Self::merge_toml_items(original_value, new_value);
321 } else {
322 original[key] = new_value.clone();
323 }
324 }
325 }
326
327 fn merge_toml_items(original: &mut toml_edit::Item, new: &toml_edit::Item) {
329 match (original, new) {
330 (toml_edit::Item::Table(orig_table), toml_edit::Item::Table(new_table)) => {
331 for (key, new_value) in new_table.iter() {
332 if let Some(orig_value) = orig_table.get_mut(key) {
333 Self::merge_toml_items(orig_value, new_value);
334 } else {
335 orig_table[key] = new_value.clone();
336 }
337 }
338 }
339 (orig, new) => {
340 *orig = new.clone();
341 }
342 }
343 }
344
345 fn project_config_path(
346 config_dir: &Path,
347 workspace_root: &Path,
348 config_file_name: &str,
349 ) -> Option<PathBuf> {
350 let project_name = Self::identify_current_project(workspace_root)?;
351 let project_config_path = config_dir
352 .join("projects")
353 .join(project_name)
354 .join("config")
355 .join(config_file_name);
356
357 if project_config_path.exists() {
358 Some(project_config_path)
359 } else {
360 None
361 }
362 }
363
364 fn identify_current_project(workspace_root: &Path) -> Option<String> {
365 let project_file = workspace_root.join(".vtcode-project");
366 if let Ok(contents) = fs::read_to_string(&project_file) {
367 let name = contents.trim();
368 if !name.is_empty() {
369 return Some(name.to_string());
370 }
371 }
372
373 workspace_root
374 .file_name()
375 .and_then(|name| name.to_str())
376 .map(|name| name.to_string())
377 }
378
379 pub fn save_config(&mut self, config: &VTCodeConfig) -> Result<()> {
381 if let Some(path) = &self.config_path {
382 Self::save_config_to_path(path, config)?;
383 } else if let Some(workspace_root) = &self.workspace_root {
384 let path = workspace_root.join(&self.config_file_name);
385 Self::save_config_to_path(path, config)?;
386 } else {
387 let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
388 let path = cwd.join(&self.config_file_name);
389 Self::save_config_to_path(path, config)?;
390 }
391
392 self.sync_from_config(config)
393 }
394
395 pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
398 self.config = config.clone();
399 Ok(())
400 }
401}