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