use std::collections::{HashMap, HashSet};
use std::time::Instant;
use irc::proto::{Command, Message as IrcMessage};
use crate::irc::formatting::{extract_nick, extract_nick_userhost};
use crate::state::AppState;
use crate::state::buffer::{Message, MessageType, NickEntry, make_buffer_id};
const MAX_NICKS_DISPLAY: usize = 15;
const BATCH_TIMEOUT_SECS: u64 = 60;
#[derive(Debug, Clone)]
pub struct BatchInfo {
pub batch_type: String,
pub params: Vec<String>,
pub messages: Vec<IrcMessage>,
pub started_at: Instant,
}
#[derive(Debug, Default)]
pub struct BatchTracker {
open: HashMap<String, BatchInfo>,
}
impl BatchTracker {
pub fn start_batch(&mut self, ref_tag: &str, batch_type: &str, params: Vec<String>) {
self.open.insert(
ref_tag.to_string(),
BatchInfo {
batch_type: batch_type.to_uppercase(),
params,
messages: Vec::new(),
started_at: Instant::now(),
},
);
}
pub fn purge_expired(&mut self) -> usize {
let timeout = std::time::Duration::from_secs(BATCH_TIMEOUT_SECS);
let before = self.open.len();
self.open.retain(|tag, info| {
let expired = info.started_at.elapsed() >= timeout;
if expired {
tracing::warn!(
"discarding expired batch tag={tag} type={} msgs={}",
info.batch_type,
info.messages.len()
);
}
!expired
});
before - self.open.len()
}
#[must_use]
pub fn is_batched(&self, msg: &IrcMessage) -> bool {
Self::get_batch_tag(msg).is_some_and(|tag| self.open.contains_key(tag))
}
pub fn add_message(&mut self, msg: IrcMessage) -> bool {
let Some(tag) = Self::get_batch_tag_owned(&msg) else {
return false;
};
if let Some(info) = self.open.get_mut(&tag) {
info.messages.push(msg);
true
} else {
false
}
}
pub fn end_batch(&mut self, ref_tag: &str) -> Option<BatchInfo> {
self.open.remove(ref_tag)
}
fn get_batch_tag(msg: &IrcMessage) -> Option<&str> {
msg.tags.as_ref().and_then(|tags| {
tags.iter()
.find(|t| t.0 == "batch")
.and_then(|t| t.1.as_deref())
})
}
fn get_batch_tag_owned(msg: &IrcMessage) -> Option<String> {
Self::get_batch_tag(msg).map(str::to_string)
}
}
pub fn process_completed_batch(state: &mut AppState, conn_id: &str, batch: &BatchInfo) {
match batch.batch_type.as_str() {
"NETSPLIT" => process_netsplit_batch(state, conn_id, batch),
"NETJOIN" => process_netjoin_batch(state, conn_id, batch),
_ => {
for msg in &batch.messages {
crate::irc::events::handle_irc_message(state, conn_id, msg);
}
}
}
}
fn process_netsplit_batch(state: &mut AppState, conn_id: &str, batch: &BatchInfo) {
let server1 = batch.params.first().map_or("???", String::as_str);
let server2 = batch.params.get(1).map_or("???", String::as_str);
let mut nicks: Vec<String> = Vec::new();
let mut nick_seen: HashSet<String> = HashSet::new();
let mut affected_buffers: HashMap<String, Vec<String>> = HashMap::new();
for msg in &batch.messages {
if let Command::QUIT(_) = &msg.command {
let Some(nick) = extract_nick(msg.prefix.as_ref()) else {
continue;
};
let nick_lower = nick.to_lowercase();
let shared: Vec<String> = state
.buffers
.iter()
.filter(|(_, buf)| {
buf.connection_id == conn_id && buf.users.contains_key(&nick_lower)
})
.map(|(id, _)| id.clone())
.collect();
for buf_id in &shared {
state.remove_nick(buf_id, &nick_lower);
affected_buffers
.entry(buf_id.clone())
.or_default()
.push(nick.clone());
}
if nick_seen.insert(nick.clone()) {
nicks.push(nick);
}
}
}
if nicks.is_empty() {
return;
}
let nick_str = format_nick_list(&nicks);
let text = format!("Netsplit {server1} \u{21C4} {server2} quits: {nick_str}");
let ts = chrono::Utc::now();
for buf_id in affected_buffers.keys() {
let id = state.next_message_id();
state.add_message(
buf_id,
Message {
id,
timestamp: ts,
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: text.clone(),
highlight: false,
event_key: Some("netsplit".to_string()),
event_params: Some(vec![server1.to_string(), server2.to_string()]),
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
}
fn process_netjoin_batch(state: &mut AppState, conn_id: &str, batch: &BatchInfo) {
let server1 = batch.params.first().map_or("???", String::as_str);
let server2 = batch.params.get(1).map_or("???", String::as_str);
let mut nicks: Vec<String> = Vec::new();
let mut nick_seen: HashSet<String> = HashSet::new();
let mut affected_buffers: HashMap<String, bool> = HashMap::new();
for msg in &batch.messages {
if let Command::JOIN(channel, account, _) = &msg.command {
let (nick, _ident, _host) = extract_nick_userhost(msg.prefix.as_ref());
let buffer_id = make_buffer_id(conn_id, channel);
let account = match account.as_deref() {
Some("*") | None => None,
Some(a) => Some(a.to_string()),
};
state.add_nick(
&buffer_id,
NickEntry {
nick: nick.clone(),
prefix: String::new(),
modes: String::new(),
away: false,
account,
ident: None,
host: None,
},
);
affected_buffers.insert(buffer_id, true);
if nick_seen.insert(nick.clone()) {
nicks.push(nick);
}
}
}
if nicks.is_empty() {
return;
}
let nick_str = format_nick_list(&nicks);
let text = format!("Netsplit over {server1} \u{21C4} {server2} joins: {nick_str}");
let ts = chrono::Utc::now();
for buf_id in affected_buffers.keys() {
let id = state.next_message_id();
state.add_message(
buf_id,
Message {
id,
timestamp: ts,
message_type: MessageType::Event,
nick: None,
nick_mode: None,
text: text.clone(),
highlight: false,
event_key: Some("netjoin".to_string()),
event_params: Some(vec![server1.to_string(), server2.to_string()]),
log_msg_id: None,
log_ref_id: None,
tags: None,
},
);
}
}
fn format_nick_list(nicks: &[String]) -> String {
if nicks.len() > MAX_NICKS_DISPLAY {
let shown: Vec<&str> = nicks[..MAX_NICKS_DISPLAY]
.iter()
.map(String::as_str)
.collect();
let more = nicks.len() - MAX_NICKS_DISPLAY;
format!("{} (+{more} more)", shown.join(", "))
} else {
let refs: Vec<&str> = nicks.iter().map(String::as_str).collect();
refs.join(", ")
}
}
#[must_use]
#[allow(dead_code)] pub fn has_batch_cap(state: &AppState, conn_id: &str) -> bool {
state
.connections
.get(conn_id)
.is_some_and(|c| c.enabled_caps.contains("batch"))
}
#[cfg(test)]
mod tests {
use super::*;
use irc::proto::message::Tag;
fn make_batched_message(batch_tag: &str, command: Command) -> IrcMessage {
IrcMessage {
tags: Some(vec![Tag("batch".to_string(), Some(batch_tag.to_string()))]),
prefix: None,
command,
}
}
fn make_plain_message(command: Command) -> IrcMessage {
IrcMessage {
tags: None,
prefix: None,
command,
}
}
fn make_quit_msg(nick: &str, reason: &str, batch_tag: &str) -> IrcMessage {
IrcMessage {
tags: Some(vec![Tag("batch".to_string(), Some(batch_tag.to_string()))]),
prefix: Some(irc::proto::Prefix::Nickname(
nick.to_string(),
"user".to_string(),
"host.net".to_string(),
)),
command: Command::QUIT(Some(reason.to_string())),
}
}
#[allow(dead_code)]
fn make_join_msg(nick: &str, channel: &str, batch_tag: &str) -> IrcMessage {
IrcMessage {
tags: Some(vec![Tag("batch".to_string(), Some(batch_tag.to_string()))]),
prefix: Some(irc::proto::Prefix::Nickname(
nick.to_string(),
"user".to_string(),
"host.net".to_string(),
)),
command: Command::JOIN(channel.to_string(), None, None),
}
}
fn make_test_server_config() -> crate::config::ServerConfig {
crate::config::ServerConfig {
label: "Test".to_string(),
address: "irc.test.net".to_string(),
port: 6697,
tls: true,
tls_verify: true,
nick: None,
username: None,
realname: None,
password: None,
sasl_user: None,
sasl_pass: None,
bind_ip: None,
channels: vec!["#test".to_string()],
encoding: None,
autoconnect: false,
auto_reconnect: None,
reconnect_delay: None,
reconnect_max_retries: None,
autosendcmd: None,
sasl_mechanism: None,
client_cert_path: None,
}
}
#[test]
fn start_and_end_batch_collects_messages() {
let mut tracker = BatchTracker::default();
tracker.start_batch("abc", "NETSPLIT", vec!["s1.net".into(), "s2.net".into()]);
let msg1 = make_batched_message("abc", Command::QUIT(Some("split".to_string())));
let msg2 = make_batched_message("abc", Command::QUIT(Some("split".to_string())));
assert!(tracker.add_message(msg1));
assert!(tracker.add_message(msg2));
let batch = tracker.end_batch("abc").expect("batch should exist");
assert_eq!(batch.batch_type, "NETSPLIT");
assert_eq!(batch.params, vec!["s1.net", "s2.net"]);
assert_eq!(batch.messages.len(), 2);
}
#[test]
fn is_batched_detects_batch_tag() {
let mut tracker = BatchTracker::default();
tracker.start_batch("ref1", "NETSPLIT", vec![]);
let batched = make_batched_message("ref1", Command::QUIT(None));
let unbatched = make_plain_message(Command::QUIT(None));
let wrong_tag = make_batched_message("ref2", Command::QUIT(None));
assert!(tracker.is_batched(&batched));
assert!(!tracker.is_batched(&unbatched));
assert!(!tracker.is_batched(&wrong_tag));
}
#[test]
fn end_nonexistent_batch_returns_none() {
let mut tracker = BatchTracker::default();
assert!(tracker.end_batch("nonexistent").is_none());
}
#[test]
fn add_message_returns_false_for_unbatched() {
let mut tracker = BatchTracker::default();
let msg = make_plain_message(Command::PRIVMSG("#test".into(), "hello".into()));
assert!(!tracker.add_message(msg));
}
#[test]
fn add_message_returns_false_for_unknown_batch() {
let mut tracker = BatchTracker::default();
let msg = make_batched_message("unknown", Command::QUIT(None));
assert!(!tracker.add_message(msg));
}
#[test]
fn netsplit_batch_produces_summary() {
let mut state = AppState::new();
let conn_id = "test";
state.add_connection(crate::state::connection::Connection {
id: conn_id.to_string(),
label: "Test".to_string(),
status: crate::state::connection::ConnectionStatus::Connected,
nick: "me".to_string(),
user_modes: String::new(),
isupport: HashMap::new(),
isupport_parsed: crate::irc::isupport::Isupport::new(),
error: None,
lag: None,
lag_pending: false,
reconnect_attempts: 0,
reconnect_delay_secs: 30,
next_reconnect: None,
should_reconnect: true,
joined_channels: vec!["#test".to_string()],
origin_config: make_test_server_config(),
enabled_caps: std::collections::HashSet::new(),
who_token_counter: 0,
local_ip: None,
silent_who_channels: std::collections::HashSet::new(),
});
let buf_id = make_buffer_id(conn_id, "#test");
state.add_buffer(crate::state::buffer::Buffer {
id: buf_id.clone(),
connection_id: conn_id.to_string(),
buffer_type: crate::state::buffer::BufferType::Channel,
name: "#test".to_string(),
messages: std::collections::VecDeque::new(),
activity: crate::state::buffer::ActivityLevel::None,
unread_count: 0,
last_read: chrono::Utc::now(),
topic: None,
topic_set_by: None,
users: HashMap::new(),
modes: None,
mode_params: None,
list_modes: HashMap::new(),
last_speakers: Vec::new(),
peer_handle: None,
});
state.add_nick(
&buf_id,
crate::state::buffer::NickEntry {
nick: "alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
state.add_nick(
&buf_id,
crate::state::buffer::NickEntry {
nick: "bob".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
let batch = BatchInfo {
batch_type: "NETSPLIT".to_string(),
params: vec!["hub.net".to_string(), "leaf.net".to_string()],
started_at: Instant::now(),
messages: vec![
make_quit_msg("alice", "hub.net leaf.net", "ref1"),
make_quit_msg("bob", "hub.net leaf.net", "ref1"),
],
};
process_completed_batch(&mut state, conn_id, &batch);
let buf = state.buffers.get(&buf_id).expect("buffer should exist");
assert!(!buf.users.contains_key("alice"));
assert!(!buf.users.contains_key("bob"));
assert!(!buf.messages.is_empty());
let last_msg = buf.messages.back().unwrap();
assert!(last_msg.text.contains("Netsplit"));
assert!(last_msg.text.contains("hub.net"));
assert!(last_msg.text.contains("leaf.net"));
assert!(last_msg.text.contains("alice"));
assert!(last_msg.text.contains("bob"));
assert!(last_msg.text.contains("quits:"));
}
#[test]
fn messages_without_batch_tag_are_not_batched() {
let mut tracker = BatchTracker::default();
tracker.start_batch("ref1", "NETSPLIT", vec![]);
let msg = make_plain_message(Command::PRIVMSG("#test".into(), "hello".into()));
assert!(!tracker.is_batched(&msg));
assert!(!tracker.add_message(msg));
}
#[test]
fn multiple_batches_tracked_independently() {
let mut tracker = BatchTracker::default();
tracker.start_batch("aaa", "NETSPLIT", vec![]);
tracker.start_batch("bbb", "NETJOIN", vec![]);
let msg_a = make_batched_message("aaa", Command::QUIT(None));
let msg_b = make_batched_message("bbb", Command::JOIN("#test".into(), None, None));
assert!(tracker.add_message(msg_a));
assert!(tracker.add_message(msg_b));
let batch_a = tracker.end_batch("aaa").expect("batch aaa");
assert_eq!(batch_a.batch_type, "NETSPLIT");
assert_eq!(batch_a.messages.len(), 1);
let batch_b = tracker.end_batch("bbb").expect("batch bbb");
assert_eq!(batch_b.batch_type, "NETJOIN");
assert_eq!(batch_b.messages.len(), 1);
}
#[test]
fn format_nick_list_under_limit() {
let nicks: Vec<String> = vec!["alice", "bob", "charlie"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(format_nick_list(&nicks), "alice, bob, charlie");
}
#[test]
fn format_nick_list_over_limit() {
let nicks: Vec<String> = (0..20).map(|i| format!("nick{i}")).collect();
let result = format_nick_list(&nicks);
assert!(result.contains("(+5 more)"));
assert!(result.contains("nick0"));
assert!(result.contains("nick14"));
assert!(!result.contains("nick15"));
}
#[test]
fn batch_type_case_normalized() {
let mut tracker = BatchTracker::default();
tracker.start_batch("ref1", "netsplit", vec![]);
let batch = tracker.end_batch("ref1").expect("batch should exist");
assert_eq!(batch.batch_type, "NETSPLIT");
}
#[test]
fn purge_expired_removes_old_batches() {
let mut tracker = BatchTracker::default();
tracker.open.insert(
"old".to_string(),
BatchInfo {
batch_type: "NETSPLIT".to_string(),
params: vec![],
messages: vec![],
started_at: Instant::now()
.checked_sub(std::time::Duration::from_secs(120))
.unwrap(),
},
);
tracker.start_batch("fresh", "NETJOIN", vec![]);
let purged = tracker.purge_expired();
assert_eq!(purged, 1);
assert!(tracker.end_batch("old").is_none());
assert!(tracker.end_batch("fresh").is_some());
}
#[test]
fn purge_expired_keeps_fresh_batches() {
let mut tracker = BatchTracker::default();
tracker.start_batch("a", "NETSPLIT", vec![]);
tracker.start_batch("b", "NETJOIN", vec![]);
let purged = tracker.purge_expired();
assert_eq!(purged, 0);
assert_eq!(tracker.open.len(), 2);
}
#[test]
fn netsplit_batch_removes_nicks_case_insensitive() {
let mut state = AppState::new();
let conn_id = "test";
state.add_connection(crate::state::connection::Connection {
id: conn_id.to_string(),
label: "Test".to_string(),
status: crate::state::connection::ConnectionStatus::Connected,
nick: "me".to_string(),
user_modes: String::new(),
isupport: HashMap::new(),
isupport_parsed: crate::irc::isupport::Isupport::new(),
error: None,
lag: None,
lag_pending: false,
reconnect_attempts: 0,
reconnect_delay_secs: 30,
next_reconnect: None,
should_reconnect: true,
joined_channels: vec!["#test".to_string()],
origin_config: make_test_server_config(),
enabled_caps: std::collections::HashSet::new(),
who_token_counter: 0,
local_ip: None,
silent_who_channels: std::collections::HashSet::new(),
});
let buf_id = make_buffer_id(conn_id, "#test");
state.add_buffer(crate::state::buffer::Buffer {
id: buf_id.clone(),
connection_id: conn_id.to_string(),
buffer_type: crate::state::buffer::BufferType::Channel,
name: "#test".to_string(),
messages: std::collections::VecDeque::new(),
activity: crate::state::buffer::ActivityLevel::None,
unread_count: 0,
last_read: chrono::Utc::now(),
topic: None,
topic_set_by: None,
users: HashMap::new(),
modes: None,
mode_params: None,
list_modes: HashMap::new(),
last_speakers: Vec::new(),
peer_handle: None,
});
state.add_nick(
&buf_id,
NickEntry {
nick: "Alice".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
state.add_nick(
&buf_id,
NickEntry {
nick: "BOB".to_string(),
prefix: String::new(),
modes: String::new(),
away: false,
account: None,
ident: None,
host: None,
},
);
let batch = BatchInfo {
batch_type: "NETSPLIT".to_string(),
params: vec!["hub.net".to_string(), "leaf.net".to_string()],
started_at: Instant::now(),
messages: vec![
make_quit_msg("Alice", "hub.net leaf.net", "ref1"),
make_quit_msg("BOB", "hub.net leaf.net", "ref1"),
],
};
process_completed_batch(&mut state, conn_id, &batch);
let buf = state.buffers.get(&buf_id).expect("buffer should exist");
assert!(!buf.users.contains_key("alice"), "alice should be removed");
assert!(!buf.users.contains_key("bob"), "bob should be removed");
assert_eq!(buf.users.len(), 0, "all users should be removed");
assert!(!buf.messages.is_empty());
let last_msg = buf.messages.back().unwrap();
assert!(last_msg.text.contains("Netsplit"));
assert!(last_msg.text.contains("Alice"));
assert!(last_msg.text.contains("BOB"));
}
}