use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WebEvent {
SyncInit {
buffers: Vec<BufferMeta>,
connections: Vec<ConnectionMeta>,
mention_count: u32,
active_buffer_id: Option<String>,
timestamp_format: String,
},
NewMessage {
buffer_id: String,
message: WireMessage,
},
TopicChanged {
buffer_id: String,
topic: Option<String>,
set_by: Option<String>,
},
NickEvent {
buffer_id: String,
kind: NickEventKind,
nick: String,
new_nick: Option<String>,
prefix: Option<String>,
modes: Option<String>,
away: Option<bool>,
message: Option<String>,
},
BufferCreated { buffer: BufferMeta },
BufferClosed { buffer_id: String },
ActivityChanged {
buffer_id: String,
activity: u8,
unread_count: u32,
},
ConnectionStatus {
conn_id: String,
label: String,
connected: bool,
nick: String,
},
MentionAlert {
buffer_id: String,
message: WireMessage,
},
Messages {
buffer_id: String,
messages: Vec<WireMessage>,
has_more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
},
NickList {
buffer_id: String,
nicks: Vec<WireNick>,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
},
MentionsList {
mentions: Vec<WireMention>,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
},
ActiveBufferChanged { buffer_id: String },
SettingsChanged {
timestamp_format: String,
line_height: f32,
theme: String,
nick_column_width: u32,
nick_max_length: u32,
nick_colors: bool,
nick_colors_in_nicklist: bool,
nick_color_saturation: f32,
nick_color_lightness: f32,
},
Error { message: String },
ShellScreen {
buffer_id: String,
cols: u16,
rows: Vec<ShellScreenRow>,
cursor_row: u16,
cursor_col: u16,
cursor_visible: bool,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WebCommand {
SendMessage { buffer_id: String, text: String },
SwitchBuffer { buffer_id: String },
MarkRead { buffer_id: String, up_to: i64 },
FetchMessages {
buffer_id: String,
limit: u32,
before: Option<i64>,
},
FetchNickList { buffer_id: String },
FetchMentions,
RunCommand { buffer_id: String, text: String },
ShellInput { buffer_id: String, data: String },
ShellResize {
buffer_id: String,
cols: u16,
rows: u16,
},
#[serde(skip)]
WebDisconnect,
#[serde(skip)]
WebConnect { initial_buffer_id: Option<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BufferMeta {
pub id: String,
pub connection_id: String,
pub name: String,
pub buffer_type: String,
pub topic: Option<String>,
pub unread_count: u32,
pub activity: u8,
pub nick_count: u32,
#[serde(default)]
pub modes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionMeta {
pub id: String,
pub label: String,
pub nick: String,
pub connected: bool,
#[serde(default)]
pub user_modes: String,
#[serde(default)]
pub lag: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireMessage {
pub id: u64,
pub timestamp: i64,
pub msg_type: String,
pub nick: Option<String>,
pub nick_mode: Option<String>,
pub text: String,
pub highlight: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireNick {
pub nick: String,
pub prefix: String,
pub modes: String,
pub away: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireMention {
pub id: i64,
pub timestamp: i64,
pub buffer_id: String,
pub channel: String,
pub nick: String,
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NickEventKind {
Join,
Part,
Quit,
NickChange,
ModeChange,
AwayChange,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellScreenRow {
pub spans: Vec<ShellSpan>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[expect(
clippy::struct_excessive_bools,
reason = "terminal cell attributes are inherently boolean flags"
)]
pub struct ShellSpan {
pub text: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub fg: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub bg: String,
#[serde(default, skip_serializing_if = "is_false")]
pub bold: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub italic: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub underline: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub inverse: bool,
}
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde skip_serializing_if requires &T"
)]
const fn is_false(b: &bool) -> bool {
!(*b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn web_event_serializes_with_type_tag() {
let event = WebEvent::BufferClosed {
buffer_id: "libera/#rust".into(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""type":"BufferClosed"#));
assert!(json.contains(r#""buffer_id":"libera/#rust"#));
}
#[test]
fn web_command_deserializes_from_json() {
let json = r#"{"type":"SendMessage","buffer_id":"libera/#rust","text":"hello"}"#;
let cmd: WebCommand = serde_json::from_str(json).unwrap();
assert!(matches!(cmd, WebCommand::SendMessage { .. }));
}
#[test]
fn wire_message_roundtrip() {
let msg = WireMessage {
id: 42,
timestamp: 1_710_000_000,
msg_type: "message".into(),
nick: Some("ferris".into()),
nick_mode: Some("@".into()),
text: "hello 🚀".into(),
highlight: false,
event_key: None,
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: WireMessage = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.id, 42);
assert_eq!(decoded.nick.as_deref(), Some("ferris"));
assert_eq!(decoded.text, "hello 🚀");
}
#[test]
fn fetch_messages_with_null_before() {
let json = r#"{"type":"FetchMessages","buffer_id":"x","limit":50,"before":null}"#;
let cmd: WebCommand = serde_json::from_str(json).unwrap();
assert!(matches!(
cmd,
WebCommand::FetchMessages { before: None, .. }
));
}
}