Skip to main content

vtcode_config/loader/
manager.rs

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/// Configuration manager for loading and validating configurations
11#[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    /// Load configuration from the default locations
22    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    /// Load configuration from a specific workspace
51    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        // 1. System config (e.g., /etc/vtcode/vtcode.toml)
62        #[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        // 2. User home config (~/.vtcode/vtcode.toml)
78        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        // 2. Project-specific config (.vtcode/projects/<project>/config/vtcode.toml)
92        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        // 3. Config directory fallback (.vtcode/vtcode.toml)
105        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        // 4. Workspace config (vtcode.toml in workspace root)
120        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 no layers found, use default config
132        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    /// Load configuration from a specific file
182    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        // 1. System config
193        #[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        // 2. User home config
209        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        // 3. The specific file provided (Workspace layer)
223        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    /// Get the loaded configuration
256    pub fn config(&self) -> &VTCodeConfig {
257        &self.config
258    }
259
260    /// Get the configuration file path (if loaded from file)
261    pub fn config_path(&self) -> Option<&Path> {
262        self.config_path.as_deref()
263    }
264
265    /// Get the configuration layer stack
266    pub fn layer_stack(&self) -> &ConfigLayerStack {
267        &self.layer_stack
268    }
269
270    /// Get the effective TOML configuration
271    pub fn effective_config(&self) -> toml::Value {
272        self.layer_stack.effective_config()
273    }
274
275    /// Get session duration from agent config
276    pub fn session_duration(&self) -> std::time::Duration {
277        std::time::Duration::from_secs(60 * 60) // Default 1 hour
278    }
279
280    /// Persist configuration to a specific path, preserving comments
281    pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
282        let path = path.as_ref();
283
284        // If file exists, preserve comments by using toml_edit
285        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            // Serialize new config to TOML value
294            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            // Update values while preserving structure and comments
301            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            // New file, just write normally
307            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    /// Merge TOML documents, preserving comments and structure from original
317    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    /// Recursively merge TOML items
328    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    /// Persist configuration to the manager's associated path or workspace
380    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    /// Sync internal config from a saved config
396    /// Call this after save_config to keep internal state in sync
397    pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
398        self.config = config.clone();
399        Ok(())
400    }
401}