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        Self::load_from_workspace(std::env::current_dir()?)
24    }
25
26    /// Load configuration from a specific workspace
27    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        // 1. System config (e.g., /etc/vtcode/vtcode.toml)
38        #[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        // 2. User home config (~/.vtcode/vtcode.toml)
54        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        // 2. Project-specific config (.vtcode/projects/<project>/config/vtcode.toml)
68        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        // 3. Config directory fallback (.vtcode/vtcode.toml)
81        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        // 4. Workspace config (vtcode.toml in workspace root)
96        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 no layers found, use default config
108        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    /// Load configuration from a specific file
158    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        // 1. System config
169        #[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        // 2. User home config
185        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        // 3. The specific file provided (Workspace layer)
199        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    /// Get the loaded configuration
232    pub fn config(&self) -> &VTCodeConfig {
233        &self.config
234    }
235
236    /// Get the configuration file path (if loaded from file)
237    pub fn config_path(&self) -> Option<&Path> {
238        self.config_path.as_deref()
239    }
240
241    /// Get the configuration layer stack
242    pub fn layer_stack(&self) -> &ConfigLayerStack {
243        &self.layer_stack
244    }
245
246    /// Get the effective TOML configuration
247    pub fn effective_config(&self) -> toml::Value {
248        self.layer_stack.effective_config()
249    }
250
251    /// Get session duration from agent config
252    pub fn session_duration(&self) -> std::time::Duration {
253        std::time::Duration::from_secs(60 * 60) // Default 1 hour
254    }
255
256    /// Persist configuration to a specific path, preserving comments
257    pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
258        let path = path.as_ref();
259
260        // If file exists, preserve comments by using toml_edit
261        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            // Serialize new config to TOML value
270            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            // Update values while preserving structure and comments
277            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            // New file, just write normally
283            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    /// Merge TOML documents, preserving comments and structure from original
293    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    /// Recursively merge TOML items
304    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    /// Persist configuration to the manager's associated path or workspace
356    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    /// Sync internal config from a saved config
372    /// Call this after save_config to keep internal state in sync
373    pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
374        self.config = config.clone();
375        Ok(())
376    }
377}