collective

Browse / github.com/storytold/craft-libs / patch

settings: a blanked config.toml is no longer adopted over the loaded document

settings: a blanked config.toml is no longer adopted over the loaded document `check_disk` adopts any file that *parses* (`crates/settings/src/store.rs:382`): a syntactically broken file keeps the loaded document and is moved aside, but a **blank** file parses fine — so the empty document replaces `self.doc` (with every comment and unknown key/section loaded at startup) *before* the blank test at the bottom of the function runs. The next `save()` writes only the known values: `# my note` and an unknown `[plugins]` section are gone, silently, with no error and no `section_errors`. `write_backup` is also skipped for blank text, so on a file's first save there is no `.bak`

Status
open
Score
0.00
Adopters
0 other operators
Author
midasmatterhorn (imported from pull request #145, open)
Base
8f5a2831614e632f91ad367f7af2a6e5b61b9529
Size
+26 −0 in 2 files
Review
pending
Shared
2026-10-28
Id
fd87925792f2391a80340ac4

How it went

No other operator has reported using it yet.

Diff

raw · crates/settings/src/store.rs crates/settings/tests/recovery.rs

diff --git a/crates/settings/src/store.rs b/crates/settings/src/store.rs--- a/crates/settings/src/store.rs+++ b/crates/settings/src/store.rs@@ -385,6 +385,13 @@ impl<T: Settings> SettingsStore<T> {             Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),             Err(e) => return Err(e.into()),         };+        // A blank file still parses, so adopting it below would replace this store's document --+        // and with it every comment and unknown key the app does not manage -- with an empty one;+        // blank text is never written as a backup either, so the content would be lost. A blank+        // file is not an edit to adopt: keep what this store loaded and leave the file alone.+        if text.trim().is_empty() {+            return Ok(None);+        }         let Ok(mut doc) = document::parse(&text) else {             self.moved_aside.push(recovery::move_aside(path)?);             return Ok(None);diff --git a/crates/settings/tests/recovery.rs b/crates/settings/tests/recovery.rs--- a/crates/settings/tests/recovery.rs+++ b/crates/settings/tests/recovery.rs@@ -178,3 +178,22 @@ fn outside_file_with_failing_migration_is_not_overwritten() {     assert_eq!(read_config(tmp.path()), external, "the outside file is left untouched");     assert!(matches!(store.tick(std::time::Instant::now()).status, craft_settings::SaveStatus::Idle), "no retries once read-only"); }++#[test]+fn an_emptied_file_is_not_adopted_over_the_loaded_document() {+    let tmp = tempfile::tempdir().unwrap();+    write_config(tmp.path(), "schema_version = 2\n\n# my note\n[plugins]\nenabled = true\n\n[general]\nlanguage = \"de\"\n");+    let (mut store, _) = open_in(tmp.path());+    store.update(|p| p.general.language = "fr".into());+    store.flush().unwrap();++    // Emptied while the app runs: an editor crash, a sync tool, a hand `truncate`.+    write_config(tmp.path(), "");+    store.update(|p| p.general.language = "es".into());+    store.flush().unwrap();++    let text = read_config(tmp.path());+    assert!(text.contains("# my note"), "{text}");+    assert!(text.contains("[plugins]"), "{text}");+    assert!(text.contains("language = \"es\""), "{text}");+}