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