use crate::common::Direction;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::Subcommand)]
#[serde(rename_all = "snake_case")]
pub enum WindowMode {
Float,
Tile,
Cycle,
}
pub const PIPE_NAME: &str = r"\\.\pipe\flow";
const PIPE_ENV: &str = "FLOW_PIPE_NAME";
pub fn pipe_name() -> String {
std::env::var(PIPE_ENV).unwrap_or_else(|_| PIPE_NAME.to_owned())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SocketMessage {
Stop,
ReloadConfig,
CheckConfig,
FocusLeft,
FocusRight,
FocusUp,
FocusDown,
SwapLeft,
SwapRight,
SwapUp,
SwapDown,
SwapColumn {
direction: Direction,
},
MoveWindow {
direction: Direction,
},
ScrollLeft,
ScrollRight,
ExpandColumn,
ShrinkColumn,
SetColumnWidth {
width_px: u32,
},
Center,
SetWindow {
mode: WindowMode,
},
ToggleFloat,
ToggleMonocle,
PlaceAbove,
Promote {
direction: Direction,
},
MergeColumn {
direction: Direction,
},
CloseWindow,
SwitchWorkspace {
workspace_id: u32,
},
SwapWorkspace {
workspace_id: u32,
},
MoveWindowToWorkspace {
workspace_id: u32,
},
QueryWindowsAll,
QueryLayoutVirtual,
QueryLayoutActual,
QueryState,
SetConfigValue {
key: String,
value: serde_json::Value,
},
ForgetApp {
exe: String,
},
ForgetAllApps,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum SocketResponse {
Ok,
Error {
message: String,
},
Data {
payload: serde_json::Value,
},
}
pub fn encode_message<T: Serialize>(msg: &T) -> Result<String, serde_json::Error> {
let mut line = serde_json::to_string(msg)?;
line.push('\n');
Ok(line)
}
pub fn decode_message<'de, T: Deserialize<'de>>(line: &'de str) -> Option<T> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
serde_json::from_str(trimmed).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_stop() {
let msg = SocketMessage::Stop;
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"{"type":"stop"}"#);
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, SocketMessage::Stop);
}
#[test]
fn roundtrip_set_column_width() {
let msg = SocketMessage::SetColumnWidth { width_px: 960 };
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"{"type":"set_column_width","width_px":960}"#);
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, SocketMessage::SetColumnWidth { width_px: 960 });
}
#[test]
fn roundtrip_swap_column() {
let msg = SocketMessage::SwapColumn {
direction: Direction::Right,
};
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"{"type":"swap_column","direction":"Right"}"#);
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed,
SocketMessage::SwapColumn {
direction: Direction::Right,
}
);
}
#[test]
fn roundtrip_move_window() {
let msg = SocketMessage::MoveWindow {
direction: Direction::Left,
};
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"{"type":"move_window","direction":"Left"}"#);
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed,
SocketMessage::MoveWindow {
direction: Direction::Left,
}
);
}
#[test]
fn roundtrip_response_ok() {
let resp = SocketResponse::Ok;
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, r#"{"status":"ok"}"#);
let parsed: SocketResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, SocketResponse::Ok);
}
#[test]
fn roundtrip_response_error() {
let resp = SocketResponse::Error {
message: "daemon busy".into(),
};
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, r#"{"status":"error","message":"daemon busy"}"#);
let parsed: SocketResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, resp);
}
#[test]
fn roundtrip_response_data() {
let resp = SocketResponse::Data {
payload: serde_json::json!({"windows": []}),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: SocketResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, resp);
}
#[test]
fn encode_adds_newline() {
let encoded = encode_message(&SocketMessage::Stop).unwrap();
assert!(encoded.ends_with('\n'));
assert_eq!(encoded, "{\"type\":\"stop\"}\n");
}
#[test]
fn decode_trims_whitespace() {
let msg: Option<SocketMessage> = decode_message(" {\"type\":\"stop\"} \n");
assert_eq!(msg, Some(SocketMessage::Stop));
}
#[test]
fn decode_empty_returns_none() {
let msg: Option<SocketMessage> = decode_message("");
assert_eq!(msg, None);
}
#[test]
fn decode_invalid_returns_none() {
let msg: Option<SocketMessage> = decode_message("not json");
assert_eq!(msg, None);
}
#[test]
fn wire_format_roundtrip() {
let msg = SocketMessage::FocusLeft;
let wire = encode_message(&msg).unwrap();
let parsed: Option<SocketMessage> = decode_message(&wire);
assert_eq!(parsed, Some(SocketMessage::FocusLeft));
}
#[test]
fn roundtrip_set_config_value() {
let msg = SocketMessage::SetConfigValue {
key: "gaps.inner".into(),
value: serde_json::json!(10),
};
let json = serde_json::to_string(&msg).unwrap();
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, msg);
}
#[test]
fn roundtrip_all_unit_variants() {
let variants = vec![
SocketMessage::ReloadConfig,
SocketMessage::CheckConfig,
SocketMessage::FocusRight,
SocketMessage::FocusUp,
SocketMessage::FocusDown,
SocketMessage::SwapLeft,
SocketMessage::SwapRight,
SocketMessage::SwapUp,
SocketMessage::SwapDown,
SocketMessage::ScrollLeft,
SocketMessage::ScrollRight,
SocketMessage::ExpandColumn,
SocketMessage::ShrinkColumn,
SocketMessage::Center,
SocketMessage::ToggleFloat,
SocketMessage::ToggleMonocle,
SocketMessage::PlaceAbove,
SocketMessage::Promote {
direction: Direction::Left,
},
SocketMessage::MergeColumn {
direction: Direction::Right,
},
SocketMessage::CloseWindow,
SocketMessage::QueryWindowsAll,
SocketMessage::QueryLayoutVirtual,
SocketMessage::QueryLayoutActual,
SocketMessage::QueryState,
SocketMessage::ForgetAllApps,
];
for msg in &variants {
let wire = encode_message(msg).unwrap();
let parsed: Option<SocketMessage> = decode_message(&wire);
assert_eq!(parsed.as_ref(), Some(msg), "roundtrip failed for: {msg:?}");
}
}
#[test]
fn roundtrip_forget_app() {
let msg = SocketMessage::ForgetApp {
exe: "firefox.exe".into(),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains(r#""forget_app""#));
assert!(json.contains(r#""exe":"firefox.exe""#));
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, msg);
}
#[test]
fn roundtrip_switch_workspace() {
let msg = SocketMessage::SwitchWorkspace { workspace_id: 3 };
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"{"type":"switch_workspace","workspace_id":3}"#);
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, SocketMessage::SwitchWorkspace { workspace_id: 3 });
}
#[test]
fn roundtrip_swap_workspace() {
let msg = SocketMessage::SwapWorkspace { workspace_id: 7 };
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"{"type":"swap_workspace","workspace_id":7}"#);
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, SocketMessage::SwapWorkspace { workspace_id: 7 });
}
#[test]
fn roundtrip_move_window_to_workspace() {
let msg = SocketMessage::MoveWindowToWorkspace { workspace_id: 11 };
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(
json,
r#"{"type":"move_window_to_workspace","workspace_id":11}"#
);
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed,
SocketMessage::MoveWindowToWorkspace { workspace_id: 11 }
);
}
#[test]
fn roundtrip_workspace_id_zero() {
for msg in [
SocketMessage::SwitchWorkspace { workspace_id: 0 },
SocketMessage::SwapWorkspace { workspace_id: 0 },
SocketMessage::MoveWindowToWorkspace { workspace_id: 0 },
] {
let wire = encode_message(&msg).unwrap();
let parsed: Option<SocketMessage> = decode_message(&wire);
assert_eq!(parsed.as_ref(), Some(&msg), "zero id failed: {wire}");
}
}
#[test]
fn wire_format_roundtrip_set_column_width() {
let msg = SocketMessage::SetColumnWidth { width_px: 1280 };
let wire = encode_message(&msg).unwrap();
let parsed: Option<SocketMessage> = decode_message(&wire);
assert_eq!(
parsed,
Some(SocketMessage::SetColumnWidth { width_px: 1280 })
);
}
#[test]
fn roundtrip_set_window_float() {
let msg = SocketMessage::SetWindow {
mode: WindowMode::Float,
};
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"{"type":"set_window","mode":"float"}"#);
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed,
SocketMessage::SetWindow {
mode: WindowMode::Float
}
);
}
#[test]
fn roundtrip_set_window_tile() {
let msg = SocketMessage::SetWindow {
mode: WindowMode::Tile,
};
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"{"type":"set_window","mode":"tile"}"#);
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed,
SocketMessage::SetWindow {
mode: WindowMode::Tile
}
);
}
#[test]
fn roundtrip_set_window_cycle() {
let msg = SocketMessage::SetWindow {
mode: WindowMode::Cycle,
};
let json = serde_json::to_string(&msg).unwrap();
assert_eq!(json, r#"{"type":"set_window","mode":"cycle"}"#);
let parsed: SocketMessage = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed,
SocketMessage::SetWindow {
mode: WindowMode::Cycle
}
);
}
#[test]
fn encode_message_returns_err_for_unserializable() {
#[derive(Debug)]
struct AlwaysFailSerialize;
impl serde::Serialize for AlwaysFailSerialize {
fn serialize<S: serde::Serializer>(&self, _s: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom(
"intentional serialization failure",
))
}
}
let result = encode_message(&AlwaysFailSerialize);
assert!(
result.is_err(),
"encode_message should return Err for failing serializer"
);
let err = result.unwrap_err();
assert!(
err.to_string()
.contains("intentional serialization failure")
);
}
#[test]
fn encode_message_succeeds_for_all_variants() {
let all_variants = vec![
SocketMessage::Stop,
SocketMessage::ReloadConfig,
SocketMessage::CheckConfig,
SocketMessage::FocusLeft,
SocketMessage::FocusRight,
SocketMessage::FocusUp,
SocketMessage::FocusDown,
SocketMessage::SwapLeft,
SocketMessage::SwapRight,
SocketMessage::SwapUp,
SocketMessage::SwapDown,
SocketMessage::SwapColumn {
direction: Direction::Right,
},
SocketMessage::MoveWindow {
direction: Direction::Left,
},
SocketMessage::ScrollLeft,
SocketMessage::ScrollRight,
SocketMessage::ExpandColumn,
SocketMessage::ShrinkColumn,
SocketMessage::Center,
SocketMessage::SetColumnWidth { width_px: 800 },
SocketMessage::SetWindow {
mode: WindowMode::Float,
},
SocketMessage::ToggleFloat,
SocketMessage::ToggleMonocle,
SocketMessage::PlaceAbove,
SocketMessage::Promote {
direction: Direction::Left,
},
SocketMessage::MergeColumn {
direction: Direction::Right,
},
SocketMessage::CloseWindow,
SocketMessage::QueryWindowsAll,
SocketMessage::QueryLayoutVirtual,
SocketMessage::QueryLayoutActual,
SocketMessage::QueryState,
SocketMessage::SetConfigValue {
key: "outer_gap".into(),
value: serde_json::json!(16),
},
SocketMessage::ForgetApp {
exe: "explorer.exe".into(),
},
SocketMessage::ForgetAllApps,
SocketMessage::SwitchWorkspace { workspace_id: 1 },
SocketMessage::SwapWorkspace { workspace_id: 2 },
SocketMessage::MoveWindowToWorkspace { workspace_id: 3 },
];
for msg in &all_variants {
let result = encode_message(msg);
assert!(result.is_ok(), "encode_message failed for variant: {msg:?}");
let wire = result.unwrap();
assert!(
wire.ends_with('\n'),
"wire format must end with newline for: {msg:?}"
);
}
}
#[test]
fn decode_unknown_type_tag_returns_none() {
let line = r#"{"type":"nonexistent_command"}"#;
let msg: Option<SocketMessage> = decode_message(line);
assert_eq!(msg, None, "unknown type tag should return None");
}
#[test]
fn decode_missing_type_tag_returns_none() {
let line = r#"{"not_a_type":"stop"}"#;
let msg: Option<SocketMessage> = decode_message(line);
assert_eq!(msg, None, "missing type tag should return None");
}
#[test]
fn decode_empty_object_returns_none() {
let line = "{}";
let msg: Option<SocketMessage> = decode_message(line);
assert_eq!(msg, None, "empty object should return None");
}
#[test]
fn decode_array_returns_none() {
let line = "[1, 2, 3]";
let msg: Option<SocketMessage> = decode_message(line);
assert_eq!(msg, None, "array input should return None");
}
#[test]
fn decode_trailing_newline() {
let line = "{\"type\":\"stop\"}\n";
let msg: Option<SocketMessage> = decode_message(line);
assert_eq!(msg, Some(SocketMessage::Stop));
}
#[test]
fn roundtrip_response_data_complex() {
let resp = SocketResponse::Data {
payload: serde_json::json!({
"windows": [
{"id": 1, "title": "Firefox", "rect": [0, 0, 960, 1080]},
{"id": 2, "title": "VS Code", "rect": [960, 0, 960, 1080]}
],
"focused": 1
}),
};
let wire = encode_message(&resp).unwrap();
let parsed: Option<SocketResponse> = decode_message(&wire);
assert_eq!(parsed, Some(resp));
}
#[test]
fn decode_response_status_on_message_returns_none() {
let line = r#"{"status":"ok"}"#;
let msg: Option<SocketMessage> = decode_message(line);
assert_eq!(
msg, None,
"response-shaped JSON should not parse as SocketMessage"
);
}
#[test]
fn decode_message_type_on_response_returns_none() {
let line = r#"{"type":"stop"}"#;
let resp: Option<SocketResponse> = decode_message(line);
assert_eq!(
resp, None,
"message-shaped JSON should not parse as SocketResponse"
);
}
}