use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoadoutFile {
pub version: u32,
pub workspaces: Vec<WorkspaceSnapshot>,
}
impl LoadoutFile {
pub const CURRENT_VERSION: u32 = 3;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceSnapshot {
pub workspace_id: u32,
pub active: bool,
pub scrolling: ScrollingSnapshot,
pub floating: Vec<FloatingEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScrollingSnapshot {
pub viewport_offset: i32,
pub focus: Option<WindowRef>,
pub columns: Vec<ColumnSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnSnapshot {
pub width_px: u32,
pub rows: Vec<RowSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RowSnapshot {
pub window: WindowRef,
pub height_px: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FloatingEntry {
pub window: WindowRef,
pub rect: RectJson,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowRef {
pub hwnd: isize,
pub exe: String,
pub title: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RectJson {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn current_version_is_three() {
assert_eq!(LoadoutFile::CURRENT_VERSION, 3);
}
#[test]
fn round_trip_full_loadout() {
let file = LoadoutFile {
version: LoadoutFile::CURRENT_VERSION,
workspaces: vec![WorkspaceSnapshot {
workspace_id: 0,
active: true,
scrolling: ScrollingSnapshot {
viewport_offset: 128,
focus: Some(WindowRef {
hwnd: 0x00_0A_0B_0C,
exe: "code.exe".into(),
title: "main.rs — flow-wm".into(),
}),
columns: vec![ColumnSnapshot {
width_px: 960,
rows: vec![RowSnapshot {
window: WindowRef {
hwnd: 0x00_0A_0B_0C,
exe: "code.exe".into(),
title: "main.rs — flow-wm".into(),
},
height_px: 600,
}],
}],
},
floating: vec![FloatingEntry {
window: WindowRef {
hwnd: 0x00_0D_0E_0F,
exe: "slack.exe".into(),
title: "Slack".into(),
},
rect: RectJson {
x: 100,
y: 100,
w: 400,
h: 300,
},
}],
}],
};
let json = serde_json::to_string(&file).expect("serialize");
let restored: LoadoutFile = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.version, LoadoutFile::CURRENT_VERSION);
assert_eq!(restored.workspaces.len(), 1);
let ws = &restored.workspaces[0];
assert_eq!(ws.workspace_id, 0);
assert!(ws.active);
assert_eq!(ws.scrolling.viewport_offset, 128);
assert!(ws.scrolling.focus.is_some());
assert_eq!(ws.scrolling.columns.len(), 1);
assert_eq!(ws.scrolling.columns[0].width_px, 960);
assert_eq!(ws.floating.len(), 1);
assert_eq!(ws.floating[0].rect.x, 100);
let row_ref = &ws.scrolling.columns[0].rows[0].window;
assert_eq!(row_ref.hwnd, 0x00_0A_0B_0C);
assert_eq!(row_ref.exe, "code.exe");
assert_eq!(row_ref.title, "main.rs — flow-wm");
assert!(json.contains("\"hwnd\""));
assert!(!json.contains("class"));
}
#[test]
fn round_trip_empty_loadout() {
let file = LoadoutFile {
version: LoadoutFile::CURRENT_VERSION,
workspaces: vec![],
};
let json = serde_json::to_string(&file).expect("serialize");
let restored: LoadoutFile = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.workspaces.len(), 0);
}
#[test]
fn missing_version_field_fails_to_deserialize() {
let json = r#"{"workspaces":[]}"#;
let result: Result<LoadoutFile, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"missing 'version' field must fail deserialization"
);
}
#[test]
fn invalid_json_fails_to_deserialize() {
let result: Result<LoadoutFile, _> = serde_json::from_str("not json {{{");
assert!(result.is_err(), "garbage input must fail deserialization");
}
#[test]
fn legacy_windowref_without_hwnd_fails_to_deserialize() {
let json = r#"{"exe":"code.exe","class":"Chrome_WidgetWin_1","title":"main.rs"}"#;
let result: Result<WindowRef, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"legacy WindowRef without `hwnd` must be rejected"
);
}
#[test]
fn legacy_version1_loadout_fails_to_deserialize() {
let json = r#"{
"version": 1,
"saved_at": "2026-07-24T12:00:00Z",
"workspaces": [{
"workspace_id": 0,
"active": true,
"scrolling": {
"viewport_offset": 0,
"focus": {"exe":"code.exe","class":"Cls","title":"x"},
"columns": [{"width_px": 800, "rows": [
{"window": {"exe":"code.exe","class":"Cls","title":"x"}, "height_px": 600}
]}]
},
"floating": []
}]
}"#;
let result: Result<LoadoutFile, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"legacy v1 loadout (no hwnd) must be rejected at parse, not silently loaded"
);
}
}