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