Skip to main content

vtcode_config/loader/manager/
persistence.rs

1//! Sparse configuration saves and comment-preserving TOML persistence.
2
3use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result, bail};
7use vtcode_commons::VtCodePaths;
8
9use super::{ConfigLayerSource, ConfigManager, VTCodeConfig};
10
11fn ensure_private_parent_dir(path: &Path) -> Result<()> {
12    let Some(parent) = path.parent() else {
13        return Ok(());
14    };
15    let _ = VtCodePaths::ensure_user_dir(parent)
16        .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
17    Ok(())
18}
19
20impl ConfigManager {
21    /// Persist configuration to a specific path, preserving comments.
22    pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
23        Self::save_config_to_path_internal(path, config, false)
24    }
25
26    /// Persist configuration to a repository-controlled path.
27    ///
28    /// Repository and project files may contain ordinary settings, but they
29    /// must never receive trusted provider definitions or provider endpoint and
30    /// credential overrides from the merged configuration. Existing protected
31    /// keys are removed as well so this method can repair files written by an
32    /// older version that flattened the effective configuration.
33    pub fn save_repository_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
34        Self::save_config_to_path_internal(path, config, true)
35    }
36
37    fn save_config_to_path_internal(
38        path: impl AsRef<Path>,
39        config: &VTCodeConfig,
40        repository_controlled: bool,
41    ) -> Result<()> {
42        let path = path.as_ref();
43        ensure_private_parent_dir(path)?;
44        let config_to_persist = if repository_controlled {
45            Self::repository_safe_config(config)
46        } else {
47            config.clone()
48        };
49        let sparse_value =
50            Self::sparse_config_value(&config_to_persist).context("Failed to prepare sparse configuration")?;
51        let sparse_content =
52            toml::to_string_pretty(&sparse_value).context("Failed to serialize sparse configuration")?;
53
54        // If file exists, preserve comments by using toml_edit. Read and
55        // publish through the shared no-follow/atomic file policy so a
56        // user-controlled symlink cannot redirect configuration writes.
57        let existing_content = match fs::symlink_metadata(path) {
58            Ok(metadata) if metadata.file_type().is_symlink() => {
59                bail!("Refusing to read symlinked config file: {}", path.display())
60            }
61            Ok(metadata) if !metadata.is_file() => {
62                bail!("Config path is not a regular file: {}", path.display())
63            }
64            Ok(_) => Some(
65                String::from_utf8(VtCodePaths::read_file_no_follow(path)?)
66                    .with_context(|| format!("Failed to read existing config: {}", path.display()))?,
67            ),
68            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
69            Err(error) => {
70                return Err(error).with_context(|| format!("Failed to inspect config: {}", path.display()));
71            }
72        };
73
74        if let Some(original_content) = existing_content {
75            let mut doc = original_content
76                .parse::<toml_edit::DocumentMut>()
77                .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
78            Self::remove_deprecated_config_keys(&mut doc);
79            if repository_controlled {
80                Self::remove_repository_provider_settings(&mut doc);
81                Self::remove_path_and_prune_empty_parents(doc.as_table_mut(), &["auth", "copilot", "command"]);
82            }
83
84            let new_doc: toml_edit::DocumentMut = sparse_content
85                .parse()
86                .context("Failed to parse sparse serialized configuration")?;
87            let default_value =
88                toml::Value::try_from(VTCodeConfig::default()).context("Failed to serialize default configuration")?;
89            let default_doc: toml_edit::DocumentMut = toml::to_string_pretty(&default_value)
90                .context("Failed to serialize default configuration")?
91                .parse()
92                .context("Failed to parse default serialized configuration")?;
93
94            // Update values while preserving structure and comments
95            Self::merge_sparse_toml_documents(&mut doc, &new_doc, &default_doc);
96            // The sparse merge cannot express the deletion of optional fields
97            // whose default serializes to absent (they appear in neither the
98            // sparse update nor the defaults), so resets issued through save
99            // paths (e.g. service_tier back to Project default) would leave
100            // stale file values behind forever. Reconcile those explicitly.
101            Self::prune_cleared_optional_fields(&mut doc, &config_to_persist);
102
103            VtCodePaths::write_private_file_atomic(path, doc.to_string().as_bytes())
104                .with_context(|| format!("Failed to write config file: {}", path.display()))?;
105        } else {
106            VtCodePaths::write_private_file_atomic(path, sparse_content.as_bytes())
107                .with_context(|| format!("Failed to write config file: {}", path.display()))?;
108        }
109
110        Ok(())
111    }
112
113    fn repository_safe_config(config: &VTCodeConfig) -> VTCodeConfig {
114        let mut safe_config = config.clone();
115        safe_config.custom_providers.clear();
116        safe_config.auth.copilot.command = None;
117        for provider_override in safe_config.provider_overrides.values_mut() {
118            provider_override.base_url = None;
119            provider_override.api_key_env = None;
120        }
121        safe_config
122    }
123
124    /// Remove file values for optional fields the effective config reset to
125    /// their absent default (e.g. `provider.openai.service_tier` back to
126    /// Project default). The sparse merge cannot express these deletions
127    /// (absent from both the sparse update and the defaults), so each entry
128    /// pairs a dotted path with a typed cleared-check. Only listed paths are
129    /// touched; foreign keys are never removed. Extend the list when a new
130    /// resettable optional reaches a save path.
131    fn prune_cleared_optional_fields(doc: &mut toml_edit::DocumentMut, config: &VTCodeConfig) {
132        // Each entry: (dotted path, is_cleared).
133        let cleared: &[(&[&str], bool)] = &[
134            (&["provider", "openai", "service_tier"], config.provider.openai.service_tier.is_none()),
135            (&["provider", "anthropic", "speed"], config.provider.anthropic.speed.is_none()),
136        ];
137        for (path, is_cleared) in cleared {
138            if *is_cleared {
139                Self::remove_path_and_prune_empty_parents(doc.as_table_mut(), path);
140            }
141        }
142    }
143
144    /// Remove `path` from `table`, then drop ancestor tables left empty.
145    /// Returns true when `table` itself is now empty.
146    fn remove_path_and_prune_empty_parents(table: &mut dyn toml_edit::TableLike, path: &[&str]) -> bool {
147        let Some((first, rest)) = path.split_first() else {
148            return table.is_empty();
149        };
150        if rest.is_empty() {
151            table.remove(first);
152        } else if let Some(child) = table.get_mut(first).and_then(toml_edit::Item::as_table_like_mut) {
153            if Self::remove_path_and_prune_empty_parents(child, rest) {
154                table.remove(first);
155            }
156        }
157        table.is_empty()
158    }
159
160    fn remove_deprecated_config_keys(doc: &mut toml_edit::DocumentMut) {
161        let table = doc.as_table_mut();
162        table.remove("project_doc_max_bytes");
163        table.remove("project_doc_fallback_filenames");
164        Self::remove_table_keys(table, "agent", &["autonomous_mode", "default_editing_mode"]);
165        Self::remove_table_keys(table, "permissions", &["allowed_tools", "disallowed_tools", "auto_permission"]);
166    }
167
168    fn remove_table_keys(table: &mut toml_edit::Table, section: &str, keys: &[&str]) {
169        let Some(section) = table.get_mut(section).and_then(toml_edit::Item::as_table_mut) else {
170            return;
171        };
172
173        for key in keys {
174            section.remove(key);
175        }
176    }
177
178    pub fn sparse_config_value(config: &VTCodeConfig) -> Result<toml::Value> {
179        let mut value = toml::Value::try_from(config).context("Failed to serialize configuration")?;
180        let default_value =
181            toml::Value::try_from(VTCodeConfig::default()).context("Failed to serialize default configuration")?;
182        Self::prune_default_values(&mut value, &default_value);
183        Ok(value)
184    }
185
186    fn prune_default_values(value: &mut toml::Value, default_value: &toml::Value) -> bool {
187        match (value, default_value) {
188            (toml::Value::Table(table), toml::Value::Table(default_table)) => {
189                table.retain(|key, child| {
190                    default_table
191                        .get(key)
192                        .is_none_or(|default_child| !Self::prune_default_values(child, default_child))
193                });
194                table.is_empty()
195            }
196            (value, default_value) => value == default_value,
197        }
198    }
199
200    /// Merge TOML documents, preserving comments and structure from original
201    fn merge_sparse_toml_documents(
202        original: &mut toml_edit::DocumentMut,
203        new: &toml_edit::DocumentMut,
204        default_doc: &toml_edit::DocumentMut,
205    ) {
206        Self::merge_sparse_tables(original.as_table_mut(), new.as_table(), default_doc.as_table());
207    }
208
209    fn merge_sparse_tables(original: &mut toml_edit::Table, new: &toml_edit::Table, default_table: &toml_edit::Table) {
210        let mut remove_keys = Vec::with_capacity(default_table.len());
211
212        for (key, default_value) in default_table.iter() {
213            if let Some(new_value) = new.get(key) {
214                if let Some(original_value) = original.get_mut(key) {
215                    Self::merge_sparse_items(original_value, new_value, default_value);
216                } else {
217                    original[key] = new_value.clone();
218                }
219            } else {
220                let Some(original_value) = original.get_mut(key) else {
221                    continue;
222                };
223                if Self::remove_known_default_item(original_value, default_value) {
224                    remove_keys.push(key.to_string());
225                }
226            }
227        }
228
229        for key in remove_keys {
230            original.remove(&key);
231        }
232
233        for (key, new_value) in new.iter() {
234            if default_table.contains_key(key) {
235                continue;
236            }
237            if let Some(original_value) = original.get_mut(key) {
238                *original_value = new_value.clone();
239            } else {
240                original[key] = new_value.clone();
241            }
242        }
243    }
244
245    fn merge_sparse_items(original: &mut toml_edit::Item, new: &toml_edit::Item, default_value: &toml_edit::Item) {
246        match (original, new, default_value) {
247            (
248                toml_edit::Item::Table(orig_table),
249                toml_edit::Item::Table(new_table),
250                toml_edit::Item::Table(default_table),
251            ) => Self::merge_sparse_tables(orig_table, new_table, default_table),
252            (orig, new, _) => {
253                *orig = new.clone();
254            }
255        }
256    }
257
258    fn remove_known_default_item(original: &mut toml_edit::Item, default_value: &toml_edit::Item) -> bool {
259        match (original, default_value) {
260            (toml_edit::Item::Table(orig_table), toml_edit::Item::Table(default_table)) => {
261                let mut remove_keys = Vec::new();
262                for (key, default_child) in default_table.iter() {
263                    let Some(orig_child) = orig_table.get_mut(key) else {
264                        continue;
265                    };
266                    if Self::remove_known_default_item(orig_child, default_child) {
267                        remove_keys.push(key.to_string());
268                    }
269                }
270                for key in remove_keys {
271                    orig_table.remove(&key);
272                }
273                orig_table.is_empty()
274            }
275            _ => true,
276        }
277    }
278
279    /// Persist configuration to the manager's associated path or workspace
280    pub fn save_config(&mut self, config: &VTCodeConfig) -> Result<()> {
281        let (target, repository_controlled) = if let Some(path) = &self.config_path {
282            if self.write_global_config_to_canonical || self.is_global_config_path(path) {
283                (self.preferred_user_config_path().unwrap_or_else(|| path.clone()), false)
284            } else {
285                (path.clone(), !self.explicit_config_is_trusted)
286            }
287        } else if let Some(workspace_root) = &self.workspace_root {
288            (workspace_root.join(&self.config_file_name), !self.explicit_config_is_trusted)
289        } else {
290            let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
291            (cwd.join(&self.config_file_name), !self.explicit_config_is_trusted)
292        };
293
294        if repository_controlled {
295            Self::save_repository_config_to_path(target, config)?;
296        } else {
297            Self::save_config_to_path(target, config)?;
298        }
299
300        #[cfg(not(test))]
301        if let Some(workspace) = &self.workspace_root {
302            Self::invalidate_workspace_cache(workspace);
303        }
304
305        self.sync_from_config(config)
306    }
307
308    fn is_global_config_path(&self, path: &Path) -> bool {
309        self.layer_stack.layers().iter().any(|layer| {
310            layer.is_enabled()
311                && matches!(layer.source, ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. })
312                && match &layer.source {
313                    ConfigLayerSource::System { file } | ConfigLayerSource::User { file } => file == path,
314                    _ => false,
315                }
316        })
317    }
318
319    /// Sync internal config from a saved config
320    /// Call this after save_config to keep internal state in sync
321    fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
322        self.config = config.clone();
323        Ok(())
324    }
325}