From 62d5345cb888458d64d92e633228d437ba2bdcdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NEDJAR?= Date: Tue, 28 Apr 2026 12:33:57 +0200 Subject: [PATCH 1/3] feat: migrate legacy fields.json on first 0.2 launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings persisted by 0.1.x stored an `EasyDapLink` directly at the JSON top level (`bootloader_path`, `firmware_path`, ...). After the wireless-stack tabbed-window refactor, the 0.2 reader expected the new shape (`{tab_daplink: {...}, tab_ws: {...}}`) and silently failed on the old one — users started fresh with empty fields. Add a one-shot fallback in load_settings: if the new shape fails, try deserializing the buffer as `TabDaplink` (which has exactly the legacy field set since the 0.1 EasyDapLink was just renamed) and lift it into `self.tab_daplink`. `tab_ws` keeps its default. The file is rewritten in the new shape on the next `CloseRequested`, making the migration transparent and one-shot. Closes #10. --- .gitignore | 1 + src/ui/main_window.rs | 34 ++++++++++++++++++++++++---------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index a9c3cf5..44fb706 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ venv/ # Added by cargo /target +CLAUDE.md \ No newline at end of file diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 1049f0d..01896ee 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -46,16 +46,7 @@ impl MainWindow { Ok(settings_dir) => { let fields_file = settings_dir.join(SETTINGS_FILE); match fs::read(fields_file) { - Ok(content) => match serde_json::from_slice::(&content) { - Ok(obj) => { - self.tab_daplink = obj.tab_daplink; - self.tab_ws = obj.tab_ws; - println!("Settings loaded !"); - } - Err(e) => { - eprintln!("Failed to deserialize settings. Error: {e}"); - } - }, + Ok(content) => self.load_settings(&content), Err(e) => eprintln!("Failed to open settings file ({e})"), } } @@ -112,4 +103,27 @@ impl MainWindow { pub fn application_subscription(&self) -> Subscription { event::listen().map(Message::ApplicationEvent) } + + /// Deserialize the on-disk settings, with a fallback to the legacy 0.1.x + /// schema (a flat `EasyDapLink` serialized at the top level — now mapped + /// onto `tab_daplink`). Successful migrations get rewritten in the new + /// shape on the next `CloseRequested`. + fn load_settings(&mut self, content: &[u8]) { + match serde_json::from_slice::(content) { + Ok(obj) => { + self.tab_daplink = obj.tab_daplink; + self.tab_ws = obj.tab_ws; + println!("Settings loaded !"); + return; + } + Err(new_err) => { + if let Ok(legacy) = serde_json::from_slice::(content) { + self.tab_daplink = legacy; + println!("Legacy settings migrated to the new schema"); + return; + } + eprintln!("Failed to deserialize settings. Error: {new_err}"); + } + } + } } From ca587ebe0163dcab617ed0a5ce54b0d8f667fe2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NEDJAR?= Date: Tue, 28 Apr 2026 12:43:33 +0200 Subject: [PATCH 2/3] fix: address Copilot review on settings migration - Log both the new-schema and legacy-schema errors when both attempts fail. Previously only the new-schema error was reported, which often said something like "missing field tab_daplink" and hid the actual reason a legacy file couldn't be parsed either. - Reword the doc comment on load_settings: it referenced a removed EasyDapLink type that no longer exists in the codebase. Now describes the legacy shape as "TabDaplink fields serialized at the top level". - Drop an unrelated CLAUDE.md addition to .gitignore that slipped into the previous commit by accident. --- .gitignore | 1 - src/ui/main_window.rs | 20 +++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 44fb706..a9c3cf5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,3 @@ venv/ # Added by cargo /target -CLAUDE.md \ No newline at end of file diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 01896ee..f2b081d 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -105,25 +105,27 @@ impl MainWindow { } /// Deserialize the on-disk settings, with a fallback to the legacy 0.1.x - /// schema (a flat `EasyDapLink` serialized at the top level — now mapped - /// onto `tab_daplink`). Successful migrations get rewritten in the new - /// shape on the next `CloseRequested`. + /// schema where `TabDaplink` fields were serialized directly at the top + /// level and are now mapped onto `tab_daplink`. Successful migrations get + /// rewritten in the new shape on the next `CloseRequested`. fn load_settings(&mut self, content: &[u8]) { match serde_json::from_slice::(content) { Ok(obj) => { self.tab_daplink = obj.tab_daplink; self.tab_ws = obj.tab_ws; println!("Settings loaded !"); - return; } - Err(new_err) => { - if let Ok(legacy) = serde_json::from_slice::(content) { + Err(new_err) => match serde_json::from_slice::(content) { + Ok(legacy) => { self.tab_daplink = legacy; println!("Legacy settings migrated to the new schema"); - return; } - eprintln!("Failed to deserialize settings. Error: {new_err}"); - } + Err(legacy_err) => { + eprintln!( + "Failed to deserialize settings. New schema error: {new_err}. Legacy schema error: {legacy_err}" + ); + } + }, } } } From c69a801d3ea0ef474b9276ebd60e4cf8f588e939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NEDJAR?= Date: Tue, 28 Apr 2026 13:13:33 +0200 Subject: [PATCH 3/3] test: cover settings migration scenarios Four unit tests on `MainWindow::load_settings` covering the test plan items announced on the PR: - loads_new_schema: 0.2 fields.json round-trips correctly. - legacy_schema_migrates_and_serializes_in_new_shape: a 0.1.x flat fields.json is loaded and re-serializing the resulting MainWindow produces the new tab_daplink/tab_ws shape with the legacy values preserved. This is the same code path as the CloseRequested save handler, so it exercises the on-disk migration without needing the GUI runtime (winit 0.30 panics on X11 close on this system, so a live end-to-end test wasn't viable). - malformed_json_keeps_defaults: error path doesn't corrupt state. - empty_content_keeps_defaults: same, against an empty buffer. `cargo test` now runs 4 tests where there were 0. --- src/ui/main_window.rs | 75 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index f2b081d..33e078a 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -129,3 +129,78 @@ impl MainWindow { } } } + +#[cfg(test)] +mod tests { + use super::*; + + const LEGACY_JSON: &[u8] = br#"{ + "bootloader_path": "/tmp/bl.bin", + "firmware_path": "/tmp/fw.bin", + "user_file_path": "/tmp/user.bin", + "target_waiting_time": 7, + "target_name": "LEGACY-STEAMI" + }"#; + + const NEW_JSON: &[u8] = br#"{ + "tab_daplink": { + "bootloader_path": "/tmp/bl.bin", + "firmware_path": "/tmp/fw.bin", + "user_file_path": "/tmp/user.bin", + "target_waiting_time": 7, + "target_name": "LEGACY-STEAMI" + }, + "tab_ws": { + "fw_selected": "BleHciExt" + } + }"#; + + #[test] + fn loads_new_schema() { + let mut w = MainWindow::default(); + w.load_settings(NEW_JSON); + let s = serde_json::to_string(&w).unwrap(); + assert!(s.contains(r#""target_name":"LEGACY-STEAMI""#)); + assert!(s.contains(r#""fw_selected":"BleHciExt""#)); + } + + #[test] + fn legacy_schema_migrates_and_serializes_in_new_shape() { + let mut w = MainWindow::default(); + w.load_settings(LEGACY_JSON); + + // Re-serialize the way the CloseRequested handler does. + let s = serde_json::to_string(&w).unwrap(); + + // New shape: top-level keys for both tabs. + assert!(s.contains(r#""tab_daplink""#), "missing tab_daplink in: {s}"); + assert!(s.contains(r#""tab_ws""#), "missing tab_ws in: {s}"); + + // Legacy data preserved under tab_daplink. + assert!(s.contains(r#""bootloader_path":"/tmp/bl.bin""#)); + assert!(s.contains(r#""firmware_path":"/tmp/fw.bin""#)); + assert!(s.contains(r#""user_file_path":"/tmp/user.bin""#)); + assert!(s.contains(r#""target_waiting_time":7"#)); + assert!(s.contains(r#""target_name":"LEGACY-STEAMI""#)); + } + + #[test] + fn malformed_json_keeps_defaults() { + let mut w = MainWindow::default(); + w.load_settings(b"{ this is not json"); + // No assertion on stderr — load_settings reports both errors via eprintln, + // exercised manually. The state must remain at defaults: serializing + // shouldn't carry any of the malformed input. + let s = serde_json::to_string(&w).unwrap(); + assert!(!s.contains("LEGACY-STEAMI")); + } + + #[test] + fn empty_content_keeps_defaults() { + let mut w = MainWindow::default(); + w.load_settings(b""); + let s = serde_json::to_string(&w).unwrap(); + assert!(s.contains(r#""tab_daplink""#)); + assert!(s.contains(r#""tab_ws""#)); + } +}