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            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); // sync_terminal_theme defaults to true
379
380        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        // An old config.toml with no such key at all must still default true.
389        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); // presets_dir_override: None
399
400        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}