use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
const SPLIT_BATCH_WAIT: Duration = Duration::from_secs(5);
const NETJOIN_BATCH_WAIT: Duration = Duration::from_secs(5);
const SPLIT_EXPIRE: Duration = Duration::from_secs(3600); const MAX_NICKS_DISPLAY: usize = 15;
#[derive(Debug, Clone)]
pub struct SplitRecord {
pub nick: String,
pub channels: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SplitGroup {
pub server1: String,
pub server2: String,
pub nicks: Vec<SplitRecord>,
pub last_quit: Instant,
pub printed: bool,
}
#[derive(Debug, Clone)]
pub struct NetjoinRecord {
pub nick: String,
pub channels: HashSet<String>,
}
#[derive(Debug, Clone)]
pub struct NetjoinGroup {
pub server1: String,
pub server2: String,
pub records: Vec<NetjoinRecord>,
pub last_join: Instant,
pub printed: bool,
}
#[derive(Debug, Clone)]
pub struct NetsplitMessage {
pub buffer_ids: Vec<String>,
pub text: String,
}
pub struct NetsplitState {
groups: Vec<SplitGroup>,
nick_index: HashMap<String, usize>,
netjoins: Vec<NetjoinGroup>,
}
impl NetsplitState {
pub fn new() -> Self {
Self {
groups: Vec::new(),
nick_index: HashMap::new(),
netjoins: Vec::new(),
}
}
pub fn handle_quit(
&mut self,
nick: &str,
message: &str,
affected_buffer_ids: &[String],
) -> bool {
if !is_netsplit_quit(message) {
return false;
}
let Some(space) = message.find(' ') else {
return false;
};
let server1 = &message[..space];
let server2 = &message[space + 1..];
let now = Instant::now();
let group_idx = self
.groups
.iter()
.position(|g| g.server1 == server1 && g.server2 == server2 && !g.printed);
let idx = if let Some(idx) = group_idx {
idx
} else {
self.groups.push(SplitGroup {
server1: server1.to_string(),
server2: server2.to_string(),
nicks: Vec::new(),
last_quit: now,
printed: false,
});
self.groups.len() - 1
};
self.groups[idx].nicks.push(SplitRecord {
nick: nick.to_string(),
channels: affected_buffer_ids.to_vec(),
});
self.groups[idx].last_quit = now;
self.nick_index.insert(nick.to_string(), idx);
true
}
pub fn handle_join(&mut self, nick: &str, buffer_id: &str) -> bool {
let Some(&group_idx) = self.nick_index.get(nick) else {
return false;
};
if group_idx >= self.groups.len() {
self.nick_index.remove(nick);
return false;
}
let server1 = self.groups[group_idx].server1.clone();
let server2 = self.groups[group_idx].server2.clone();
let now = Instant::now();
let nj_idx = self
.netjoins
.iter()
.position(|nj| nj.server1 == server1 && nj.server2 == server2 && !nj.printed);
let nj_index = if let Some(idx) = nj_idx {
idx
} else {
self.netjoins.push(NetjoinGroup {
server1,
server2,
records: Vec::new(),
last_join: now,
printed: false,
});
self.netjoins.len() - 1
};
if let Some(nj) = self.netjoins.get_mut(nj_index) {
if let Some(rec) = nj.records.iter_mut().find(|r| r.nick == nick) {
rec.channels.insert(buffer_id.to_string());
} else {
nj.records.push(NetjoinRecord {
nick: nick.to_string(),
channels: HashSet::from([buffer_id.to_string()]),
});
}
nj.last_join = now;
}
self.nick_index.remove(nick);
true
}
pub fn tick(&mut self) -> Vec<NetsplitMessage> {
let now = Instant::now();
let mut messages = Vec::new();
for group in &mut self.groups {
if !group.printed && now.duration_since(group.last_quit) >= SPLIT_BATCH_WAIT {
messages.extend(format_split_messages(group));
group.printed = true;
}
}
for nj in &mut self.netjoins {
if !nj.printed && now.duration_since(nj.last_join) >= NETJOIN_BATCH_WAIT {
messages.extend(format_netjoin_messages(nj));
nj.printed = true;
}
}
self.groups
.retain(|g| now.duration_since(g.last_quit) < SPLIT_EXPIRE);
self.netjoins
.retain(|nj| now.duration_since(nj.last_join) < SPLIT_EXPIRE);
self.nick_index.clear();
for (idx, group) in self.groups.iter().enumerate() {
for rec in &group.nicks {
self.nick_index.insert(rec.nick.clone(), idx);
}
}
messages
}
#[allow(dead_code)] pub fn is_expired_split_nick(&self, nick: &str) -> bool {
if let Some(&idx) = self.nick_index.get(nick)
&& idx < self.groups.len()
{
let elapsed = Instant::now().duration_since(self.groups[idx].last_quit);
return elapsed >= SPLIT_EXPIRE;
}
false
}
}
impl Default for NetsplitState {
fn default() -> Self {
Self::new()
}
}
pub fn is_netsplit_quit(message: &str) -> bool {
if message.is_empty() {
return false;
}
if message.contains(':') || message.contains('/') {
return false;
}
let space = match message.find(' ') {
Some(idx) if idx > 0 && idx < message.len() - 1 => idx,
_ => return false,
};
if message[space + 1..].contains(' ') {
return false;
}
let host1 = &message[..space];
let host2 = &message[space + 1..];
is_valid_split_host(host1) && is_valid_split_host(host2) && host1 != host2
}
fn is_valid_split_host(host: &str) -> bool {
if host.len() < 3 {
return false;
}
if host.starts_with('.') || host.ends_with('.') {
return false;
}
if host.contains("..") {
return false;
}
let dot = match host.rfind('.') {
Some(idx) if idx > 0 => idx,
_ => return false,
};
let tld = &host[dot + 1..];
if tld.len() < 2 {
return false;
}
if !tld.chars().all(|c| c.is_ascii_alphabetic()) {
return false;
}
true
}
fn format_split_messages(group: &SplitGroup) -> Vec<NetsplitMessage> {
let mut channel_nicks: HashMap<&str, Vec<&str>> = HashMap::new();
for rec in &group.nicks {
for ch in &rec.channels {
channel_nicks
.entry(ch.as_str())
.or_default()
.push(&rec.nick);
}
}
channel_nicks
.into_iter()
.map(|(channel, nicks)| {
let nick_str = format_nick_list(&nicks);
NetsplitMessage {
buffer_ids: vec![channel.to_string()],
text: format!(
"Netsplit {} \u{21C4} {} quits: {}",
group.server1, group.server2, nick_str
),
}
})
.collect()
}
fn format_netjoin_messages(group: &NetjoinGroup) -> Vec<NetsplitMessage> {
let mut channel_nicks: HashMap<&str, Vec<&str>> = HashMap::new();
for rec in &group.records {
for ch in &rec.channels {
channel_nicks
.entry(ch.as_str())
.or_default()
.push(&rec.nick);
}
}
channel_nicks
.into_iter()
.map(|(channel, nicks)| {
let nick_str = format_nick_list(&nicks);
NetsplitMessage {
buffer_ids: vec![channel.to_string()],
text: format!(
"Netsplit over {} \u{21C4} {} joins: {}",
group.server1, group.server2, nick_str
),
}
})
.collect()
}
fn format_nick_list(nicks: &[&str]) -> String {
if nicks.len() > MAX_NICKS_DISPLAY {
let shown = nicks[..MAX_NICKS_DISPLAY].join(", ");
let more = nicks.len() - MAX_NICKS_DISPLAY;
format!("{shown} (+{more} more)")
} else {
nicks.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_netsplit_message() {
assert!(is_netsplit_quit("irc.server1.net irc.server2.net"));
assert!(is_netsplit_quit("hub.eu.libera.chat services.libera.chat"));
assert!(is_netsplit_quit("a.bc d.ef"));
}
#[test]
fn rejects_empty() {
assert!(!is_netsplit_quit(""));
}
#[test]
fn rejects_no_space() {
assert!(!is_netsplit_quit("irc.server1.net"));
}
#[test]
fn rejects_multiple_spaces() {
assert!(!is_netsplit_quit("irc.server1.net irc.server2.net extra"));
}
#[test]
fn rejects_colon() {
assert!(!is_netsplit_quit("Quit: Connection reset"));
}
#[test]
fn rejects_slash() {
assert!(!is_netsplit_quit("http://example.com something.net"));
}
#[test]
fn rejects_same_host() {
assert!(!is_netsplit_quit("irc.server.net irc.server.net"));
}
#[test]
fn rejects_short_host() {
assert!(!is_netsplit_quit("ab cd.ef"));
}
#[test]
fn rejects_no_dot() {
assert!(!is_netsplit_quit("servername othername"));
}
#[test]
fn rejects_leading_dot() {
assert!(!is_netsplit_quit(".irc.server.net irc.other.net"));
}
#[test]
fn rejects_trailing_dot() {
assert!(!is_netsplit_quit("irc.server.net. irc.other.net"));
}
#[test]
fn rejects_double_dot() {
assert!(!is_netsplit_quit("irc..server.net irc.other.net"));
}
#[test]
fn rejects_numeric_tld() {
assert!(!is_netsplit_quit("server.123 other.net"));
}
#[test]
fn rejects_single_char_tld() {
assert!(!is_netsplit_quit("server.a other.net"));
}
#[test]
fn rejects_space_at_start() {
assert!(!is_netsplit_quit(" server.net other.net"));
}
#[test]
fn rejects_space_at_end() {
assert!(!is_netsplit_quit("server.net "));
}
#[test]
fn handle_quit_returns_false_for_normal_quit() {
let mut state = NetsplitState::new();
assert!(!state.handle_quit("nick", "Client quit", &[]));
}
#[test]
fn handle_quit_returns_true_for_netsplit() {
let mut state = NetsplitState::new();
let result = state.handle_quit(
"alice",
"irc.hub.net irc.leaf.net",
&["conn/#channel".to_string()],
);
assert!(result);
assert_eq!(state.groups.len(), 1);
assert_eq!(state.groups[0].nicks.len(), 1);
assert_eq!(state.groups[0].server1, "irc.hub.net");
assert_eq!(state.groups[0].server2, "irc.leaf.net");
}
#[test]
fn handle_quit_batches_same_server_pair() {
let mut state = NetsplitState::new();
state.handle_quit("alice", "hub.net leaf.net", &["conn/#chan".to_string()]);
state.handle_quit("bob", "hub.net leaf.net", &["conn/#chan".to_string()]);
assert_eq!(state.groups.len(), 1);
assert_eq!(state.groups[0].nicks.len(), 2);
}
#[test]
fn handle_quit_separates_different_server_pairs() {
let mut state = NetsplitState::new();
state.handle_quit("alice", "hub.net leaf.net", &[]);
state.handle_quit("bob", "other.net leaf.net", &[]);
assert_eq!(state.groups.len(), 2);
}
#[test]
fn handle_join_returns_false_for_unknown_nick() {
let mut state = NetsplitState::new();
assert!(!state.handle_join("unknown", "conn/#chan"));
}
#[test]
fn handle_join_returns_true_for_split_nick() {
let mut state = NetsplitState::new();
state.handle_quit("alice", "hub.net leaf.net", &["conn/#chan".to_string()]);
assert!(state.handle_join("alice", "conn/#chan"));
assert_eq!(state.netjoins.len(), 1);
assert_eq!(state.netjoins[0].records.len(), 1);
assert_eq!(state.netjoins[0].records[0].nick, "alice");
}
#[test]
fn handle_join_removes_from_nick_index() {
let mut state = NetsplitState::new();
state.handle_quit("alice", "hub.net leaf.net", &[]);
assert!(state.nick_index.contains_key("alice"));
state.handle_join("alice", "conn/#chan");
assert!(!state.nick_index.contains_key("alice"));
}
#[test]
fn handle_join_deduplicates_nicks() {
let mut state = NetsplitState::new();
state.handle_quit(
"alice",
"hub.net leaf.net",
&["conn/#a".to_string(), "conn/#b".to_string()],
);
state.handle_join("alice", "conn/#a");
assert!(!state.handle_join("alice", "conn/#b"));
}
#[test]
fn tick_returns_empty_before_batch_wait() {
let mut state = NetsplitState::new();
state.handle_quit("alice", "hub.net leaf.net", &["conn/#chan".to_string()]);
let msgs = state.tick();
assert!(msgs.is_empty());
}
#[test]
fn format_nick_list_under_limit() {
let nicks: Vec<&str> = (0..5)
.map(|i| match i {
0 => "a",
1 => "b",
2 => "c",
3 => "d",
_ => "e",
})
.collect();
let result = format_nick_list(&nicks);
assert_eq!(result, "a, b, c, d, e");
}
#[test]
fn format_nick_list_over_limit() {
let names: Vec<String> = (0..20).map(|i| format!("nick{i}")).collect();
let nicks: Vec<&str> = names.iter().map(String::as_str).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 format_split_messages_per_channel() {
let group = SplitGroup {
server1: "hub.net".to_string(),
server2: "leaf.net".to_string(),
nicks: vec![
SplitRecord {
nick: "alice".to_string(),
channels: vec!["conn/#a".to_string(), "conn/#b".to_string()],
},
SplitRecord {
nick: "bob".to_string(),
channels: vec!["conn/#a".to_string()],
},
],
last_quit: Instant::now(),
printed: false,
};
let msgs = format_split_messages(&group);
assert_eq!(msgs.len(), 2);
let chan_a = msgs.iter().find(|m| m.buffer_ids == ["conn/#a"]).unwrap();
assert!(chan_a.text.contains("alice"));
assert!(chan_a.text.contains("bob"));
let chan_b = msgs.iter().find(|m| m.buffer_ids == ["conn/#b"]).unwrap();
assert!(chan_b.text.contains("alice"));
assert!(!chan_b.text.contains("bob"));
}
#[test]
fn format_netjoin_messages_per_channel() {
let mut ch_a = HashSet::new();
ch_a.insert("conn/#a".to_string());
let mut ch_both = HashSet::new();
ch_both.insert("conn/#a".to_string());
ch_both.insert("conn/#b".to_string());
let group = NetjoinGroup {
server1: "hub.net".to_string(),
server2: "leaf.net".to_string(),
records: vec![
NetjoinRecord {
nick: "alice".to_string(),
channels: ch_both,
},
NetjoinRecord {
nick: "bob".to_string(),
channels: ch_a,
},
],
last_join: Instant::now(),
printed: false,
};
let msgs = format_netjoin_messages(&group);
assert_eq!(msgs.len(), 2);
let chan_a = msgs.iter().find(|m| m.buffer_ids == ["conn/#a"]).unwrap();
assert!(chan_a.text.contains("alice"));
assert!(chan_a.text.contains("bob"));
assert!(chan_a.text.contains("Netsplit over"));
let chan_b = msgs.iter().find(|m| m.buffer_ids == ["conn/#b"]).unwrap();
assert!(chan_b.text.contains("alice"));
assert!(!chan_b.text.contains("bob"));
}
#[test]
fn is_expired_split_nick_unknown_nick() {
let state = NetsplitState::new();
assert!(!state.is_expired_split_nick("nobody"));
}
#[test]
fn is_expired_split_nick_recent() {
let mut state = NetsplitState::new();
state.handle_quit("alice", "hub.net leaf.net", &[]);
assert!(!state.is_expired_split_nick("alice"));
}
#[test]
fn default_impl_matches_new() {
let a = NetsplitState::new();
let b = NetsplitState::default();
assert!(a.groups.is_empty());
assert!(b.groups.is_empty());
}
#[test]
fn valid_split_host_examples() {
assert!(is_valid_split_host("irc.server.net"));
assert!(is_valid_split_host("hub.eu.libera.chat"));
assert!(is_valid_split_host("a.bc")); }
#[test]
fn invalid_split_host_examples() {
assert!(!is_valid_split_host("ab")); assert!(!is_valid_split_host(".a.bc")); assert!(!is_valid_split_host("a.bc.")); assert!(!is_valid_split_host("a..bc")); assert!(!is_valid_split_host("abc")); assert!(!is_valid_split_host("a.1")); assert!(!is_valid_split_host("a.b")); }
}