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