vtcode_config/loader/manager/
persistence.rs1use 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 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 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 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 Self::merge_sparse_toml_documents(&mut doc, &new_doc, &default_doc);
96 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 fn prune_cleared_optional_fields(doc: &mut toml_edit::DocumentMut, config: &VTCodeConfig) {
132 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 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 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 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 fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
322 self.config = config.clone();
323 Ok(())
324 }
325}