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