Skip to main content

cli/config/
save.rs

1//! Config persistence: atomic writes, comment preservation, and sparse
2//! project-layer diffing so inherited global values never materialize in
3//! project config files.
4
5use 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            // Executable sys-code permission is intentionally global-only. Never preserve or
55            // materialize it in a project configuration that could authorize its own presets.
56            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); // sync_terminal_theme defaults to true
378
379        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        // An old config.toml with no such key at all must still default true.
388        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); // presets_dir_override: None
398
399        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}