1use anyhow::{Context, Result, bail};
6use std::path::PathBuf;
7use tokio::fs;
8
9use super::Config;
10
11impl Config {
12 pub async fn save(&self) -> Result<()> {
13 let config_path = self.resolve_config_path_for_save().await?;
14
15 let new_table = self.serialize_table_for_save()?;
16 let new_toml = toml::to_string_pretty(&new_table).context("Failed to serialize config")?;
17
18 let toml_str = if config_path.exists() {
19 let existing = fs::read_to_string(&config_path).await.unwrap_or_default();
20 if existing.is_empty() {
21 new_toml
22 } else {
23 let mut doc: toml_edit::DocumentMut = existing
24 .parse()
25 .context("Fail to parse existing config for comment preservation")?;
26
27 utils::migration::sync_table(doc.as_table_mut(), &new_table);
28 doc.to_string()
29 }
30 } else {
31 new_toml
32 };
33
34 crate::persist::atomic_write(&config_path, toml_str.as_bytes())
35 .await
36 .with_context(|| format!("Failed to write config to {config_path:?}"))?;
37
38 Ok(())
39 }
40
41 fn serialize_table_for_save(&self) -> Result<toml::Table> {
42 let table = self.serialize_effective_table()?;
43
44 if self.is_project_config {
45 let mut sparse = if let Some(state) = &self.project_save_state {
46 let mut sparse = state.original.clone();
47 apply_table_changes(&mut sparse, &state.loaded, &table);
48 sparse
49 } else {
50 table
51 };
52 sparse.remove("schema_version");
53 sparse.remove("last_cleared_schema_version");
54 sparse.remove("allow_sys_code");
57 return Ok(sparse);
58 }
59
60 Ok(table)
61 }
62
63 pub(super) fn serialize_effective_table(&self) -> Result<toml::Table> {
64 let serialized = toml::to_string_pretty(self).context("Failed to serialize config")?;
65 toml::from_str(&serialized).context("Failed to round-trip serialize config")
66 }
67
68 async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
69 if self
70 .config_path
71 .parent()
72 .is_some_and(|parent| !parent.as_os_str().is_empty())
73 {
74 return Ok(self.config_path.clone());
75 }
76 bail!("config path must not be empty");
77 }
78}
79
80fn apply_table_changes(target: &mut toml::Table, loaded: &toml::Table, current: &toml::Table) {
81 let keys: std::collections::BTreeSet<_> = loaded.keys().chain(current.keys()).collect();
82 for key in keys {
83 match (loaded.get(key), current.get(key)) {
84 (Some(toml::Value::Table(before)), Some(toml::Value::Table(after))) => {
85 let entry = target
86 .entry(key.clone())
87 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
88 if !entry.is_table() {
89 *entry = toml::Value::Table(toml::Table::new());
90 }
91 apply_table_changes(entry.as_table_mut().unwrap(), before, after);
92 if entry.as_table().is_some_and(toml::Table::is_empty) {
93 target.remove(key);
94 }
95 }
96 (before, after) if before == after => {}
97 (_, Some(value)) => {
98 target.insert(key.clone(), value.clone());
99 }
100 (_, None) => {
101 target.remove(key);
102 }
103 }
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::super::test_util::{config_in, make_temp_dir};
110 use super::*;
111 use crate::config::CURRENT_RUNTIME_SCHEMA_VERSION;
112 use std::path::Path;
113
114 #[tokio::test]
115 async fn save_writes_config_file_for_new_config() {
116 let dir = make_temp_dir().await;
117 let config = config_in(&dir);
118
119 config.save().await.unwrap();
120
121 let content = fs::read_to_string(&config.config_path).await.unwrap();
122 let parsed: toml::Table = toml::from_str(&content).unwrap();
123 assert_eq!(
124 parsed["schema_version"].as_integer(),
125 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
126 );
127
128 fs::remove_dir_all(&dir).await.unwrap();
129 }
130
131 #[tokio::test]
132 async fn save_writes_new_toml_when_existing_file_is_empty() {
133 let dir = make_temp_dir().await;
134 let config = config_in(&dir);
135 fs::write(&config.config_path, b"").await.unwrap();
136
137 config.save().await.unwrap();
138
139 let content = fs::read_to_string(&config.config_path).await.unwrap();
140 assert!(!content.is_empty());
141 let parsed: toml::Table = toml::from_str(&content).unwrap();
142 assert!(parsed.contains_key("schema_version"));
143
144 fs::remove_dir_all(&dir).await.unwrap();
145 }
146
147 #[tokio::test]
148 async fn save_merges_updates_changed_value() {
149 let dir = make_temp_dir().await;
150 let config = config_in(&dir);
151 fs::write(&config.config_path, "schema_version = 0\n")
152 .await
153 .unwrap();
154
155 let updated = Config {
156 schema_version: 2,
157 ..config
158 };
159 updated.save().await.unwrap();
160
161 let content = fs::read_to_string(&updated.config_path).await.unwrap();
162 let parsed: toml::Table = toml::from_str(&content).unwrap();
163 assert_eq!(parsed["schema_version"].as_integer(), Some(2));
164
165 fs::remove_dir_all(&dir).await.unwrap();
166 }
167
168 #[tokio::test]
169 async fn save_writes_last_cleared_schema_version_when_set() {
170 let dir = make_temp_dir().await;
171 let mut config = config_in(&dir);
172 config.last_cleared_schema_version = Some(1);
173
174 config.save().await.unwrap();
175
176 let content = fs::read_to_string(&config.config_path).await.unwrap();
177 let parsed: toml::Table = toml::from_str(&content).unwrap();
178 assert_eq!(parsed["last_cleared_schema_version"].as_integer(), Some(1));
179
180 fs::remove_dir_all(&dir).await.unwrap();
181 }
182
183 #[tokio::test]
184 async fn save_merges_preserves_comments() {
185 let dir = make_temp_dir().await;
186 let mut config = config_in(&dir);
187 config.schema_version = 0;
188 fs::write(&config.config_path, "# keep this\nschema_version = 0\n")
189 .await
190 .unwrap();
191
192 config.save().await.unwrap();
193
194 let content = fs::read_to_string(&config.config_path).await.unwrap();
195 assert!(
196 content.contains("# keep this"),
197 "comment should be preserved"
198 );
199
200 fs::remove_dir_all(&dir).await.unwrap();
201 }
202
203 #[tokio::test]
204 async fn save_updates_detailed_env_value_without_losing_description() {
205 let dir = make_temp_dir().await;
206 let mut config = config_in(&dir);
207 fs::write(
208 &config.config_path,
209 "[env]\nMY_TOKEN = { value = \"old\", description = \"Internal token\" }\n",
210 )
211 .await
212 .unwrap();
213 config.env.insert("MY_TOKEN".into(), "new".into());
214
215 config.save().await.unwrap();
216
217 let content = fs::read_to_string(&config.config_path).await.unwrap();
218 assert!(
219 content.contains("MY_TOKEN = { value = \"new\", description = \"Internal token\" }")
220 );
221 fs::remove_dir_all(&dir).await.unwrap();
222 }
223
224 #[tokio::test]
225 async fn save_merges_removes_stale_keys() {
226 let dir = make_temp_dir().await;
227 let config = config_in(&dir);
228 fs::write(
229 &config.config_path,
230 "schema_version = 0\nstale_key = \"old\"\n",
231 )
232 .await
233 .unwrap();
234
235 config.save().await.unwrap();
236
237 let content = fs::read_to_string(&config.config_path).await.unwrap();
238 assert!(
239 !content.contains("stale_key"),
240 "stale key should be removed"
241 );
242
243 fs::remove_dir_all(&dir).await.unwrap();
244 }
245
246 #[tokio::test]
247 async fn save_returns_error_for_path_without_parent() {
248 let config = Config {
249 config_path: PathBuf::from("config.toml"),
250 ..Config::new_for_test(Path::new("shine"))
251 };
252 assert!(config.save().await.is_err());
253 }
254
255 #[tokio::test]
256 async fn presets_dir_override_round_trips_through_save() {
257 let dir = make_temp_dir().await;
258 let mut config = config_in(&dir);
259 config.presets_dir_override = Some(PathBuf::from("/external/presets"));
260
261 config.save().await.unwrap();
262
263 let content = fs::read_to_string(&config.config_path).await.unwrap();
264 assert!(
265 content.contains("/external/presets"),
266 "presets_dir should be written to config.toml"
267 );
268
269 let loaded: Config = toml::from_str(&content).unwrap();
270 assert_eq!(
271 loaded.presets_dir_override,
272 Some(PathBuf::from("/external/presets"))
273 );
274
275 fs::remove_dir_all(&dir).await.unwrap();
276 }
277
278 #[tokio::test]
279 async fn presets_overlay_dir_override_round_trips_through_save() {
280 let dir = make_temp_dir().await;
281 let mut config = config_in(&dir);
282 config.presets_overlay_dir_override = Some(PathBuf::from("/external/overlay"));
283
284 config.save().await.unwrap();
285
286 let content = fs::read_to_string(&config.config_path).await.unwrap();
287 assert!(content.contains("presets_overlay_dir"));
288
289 let loaded: Config = toml::from_str(&content).unwrap();
290 assert_eq!(
291 loaded.presets_overlay_dir_override,
292 Some(PathBuf::from("/external/overlay"))
293 );
294
295 fs::remove_dir_all(&dir).await.unwrap();
296 }
297
298 #[tokio::test]
299 async fn presets_overlay_git_round_trips_through_save() {
300 let dir = make_temp_dir().await;
301 let mut config = config_in(&dir);
302 config.presets_overlay_git = Some("https://example.com/overlay.git".to_string());
303 config.presets_overlay_git_branch = Some("main".to_string());
304
305 config.save().await.unwrap();
306
307 let content = fs::read_to_string(&config.config_path).await.unwrap();
308 assert!(content.contains("presets_overlay_git"));
309
310 let loaded: Config = toml::from_str(&content).unwrap();
311 assert_eq!(
312 loaded.presets_overlay_git.as_deref(),
313 Some("https://example.com/overlay.git")
314 );
315 assert_eq!(loaded.presets_overlay_git_branch.as_deref(), Some("main"));
316
317 fs::remove_dir_all(&dir).await.unwrap();
318 }
319
320 #[tokio::test]
321 async fn gpg_recipients_round_trip_through_save() {
322 let dir = make_temp_dir().await;
323 let mut config = config_in(&dir);
324 config.gpg_recipients = vec![
325 "alice@example.com".to_string(),
326 "bob@example.com".to_string(),
327 ];
328
329 config.save().await.unwrap();
330
331 let content = fs::read_to_string(&config.config_path).await.unwrap();
332 let loaded: Config = toml::from_str(&content).unwrap();
333 assert_eq!(
334 loaded.gpg_recipients,
335 ["alice@example.com", "bob@example.com"]
336 );
337
338 fs::remove_dir_all(&dir).await.unwrap();
339 }
340
341 #[tokio::test]
342 async fn allow_app_hooks_round_trips_through_save() {
343 let dir = make_temp_dir().await;
344 let mut config = config_in(&dir);
345 config.allow_app_hooks = true;
346
347 config.save().await.unwrap();
348
349 let content = fs::read_to_string(&config.config_path).await.unwrap();
350 let loaded: Config = toml::from_str(&content).unwrap();
351 assert!(loaded.allow_app_hooks);
352
353 fs::remove_dir_all(&dir).await.unwrap();
354 }
355
356 #[tokio::test]
357 async fn allow_sys_code_round_trips_through_save() {
358 let dir = make_temp_dir().await;
359 let mut config = config_in(&dir);
360 config.allow_sys_code = true;
361
362 config.save().await.unwrap();
363
364 let content = fs::read_to_string(&config.config_path).await.unwrap();
365 let loaded: Config = toml::from_str(&content).unwrap();
366 assert!(loaded.allow_sys_code);
367
368 fs::remove_dir_all(&dir).await.unwrap();
369 }
370
371 #[tokio::test]
372 async fn sync_terminal_theme_false_round_trips_through_save() {
373 let dir = make_temp_dir().await;
374 let mut config = config_in(&dir);
375 config.sync_terminal_theme = false;
376
377 config.save().await.unwrap();
378
379 let content = fs::read_to_string(&config.config_path).await.unwrap();
380 assert!(content.contains("sync_terminal_theme"));
381 let loaded: Config = toml::from_str(&content).unwrap();
382 assert!(!loaded.sync_terminal_theme);
383
384 fs::remove_dir_all(&dir).await.unwrap();
385 }
386
387 #[tokio::test]
388 async fn sync_terminal_theme_true_default_is_absent_from_toml() {
389 let dir = make_temp_dir().await;
390 let config = config_in(&dir); config.save().await.unwrap();
393
394 let content = fs::read_to_string(&config.config_path).await.unwrap();
395 let parsed: toml::Table = toml::from_str(&content).unwrap();
396 assert!(
397 !parsed.contains_key("sync_terminal_theme"),
398 "default true value must not clutter a fresh config.toml"
399 );
400 let loaded: Config = toml::from_str(&content).unwrap();
402 assert!(loaded.sync_terminal_theme);
403
404 fs::remove_dir_all(&dir).await.unwrap();
405 }
406
407 #[tokio::test]
408 async fn presets_dir_absent_from_toml_when_override_is_none() {
409 let dir = make_temp_dir().await;
410 let config = config_in(&dir); config.save().await.unwrap();
413
414 let content = fs::read_to_string(&config.config_path).await.unwrap();
415 let parsed: toml::Table = toml::from_str(&content).unwrap();
416 assert!(
417 !parsed.contains_key("presets_dir"),
418 "presets_dir key must be absent when override is None"
419 );
420
421 fs::remove_dir_all(&dir).await.unwrap();
422 }
423}