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