pub mod batch;
pub mod cap;
pub mod events;
pub mod extban;
pub mod flood;
pub mod formatting;
pub mod ignore;
pub mod isupport;
pub mod netsplit;
pub mod sasl_scram;
use std::collections::HashSet;
use base64::Engine as _;
use color_eyre::eyre::{Result, eyre};
use futures::StreamExt;
use irc::client::prelude::*;
use tokio::sync::mpsc;
use crate::irc::cap::{DESIRED_CAPS, ServerCaps};
#[derive(Debug)]
pub enum IrcEvent {
Message(String, Box<irc::proto::Message>),
Connected(String, HashSet<String>),
Disconnected(String, Option<String>),
HandleReady(
String,
irc::client::Sender,
Option<std::net::IpAddr>,
Option<tokio::task::JoinHandle<()>>,
),
NegotiationInfo(String, Vec<String>),
}
struct NegotiateResult {
enabled_caps: HashSet<String>,
diagnostics: Vec<String>,
early_messages: Vec<irc::proto::Message>,
}
pub struct IrcHandle {
pub conn_id: String,
pub sender: irc::client::Sender,
pub local_ip: Option<std::net::IpAddr>,
pub outgoing_handle: Option<tokio::task::JoinHandle<()>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaslMechanism {
Plain,
External,
ScramSha256,
}
impl std::fmt::Display for SaslMechanism {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Plain => write!(f, "PLAIN"),
Self::External => write!(f, "EXTERNAL"),
Self::ScramSha256 => write!(f, "SCRAM-SHA-256"),
}
}
}
#[must_use]
pub fn select_sasl_mechanism(
server_mechanisms: &[String],
sasl_mechanism_override: Option<&str>,
has_client_cert: bool,
has_credentials: bool,
) -> Option<SaslMechanism> {
let server_has = |mech: &str| {
server_mechanisms
.iter()
.any(|m| m.eq_ignore_ascii_case(mech))
};
if let Some(override_mech) = sasl_mechanism_override {
return match override_mech.to_ascii_uppercase().as_str() {
"EXTERNAL" if server_has("EXTERNAL") && has_client_cert => {
Some(SaslMechanism::External)
}
"SCRAM-SHA-256" if server_has("SCRAM-SHA-256") && has_credentials => {
Some(SaslMechanism::ScramSha256)
}
"PLAIN" if server_has("PLAIN") && has_credentials => Some(SaslMechanism::Plain),
_ => {
tracing::warn!(
"configured SASL mechanism '{override_mech}' not available \
(server offers: {}, cert={has_client_cert}, creds={has_credentials})",
server_mechanisms.join(",")
);
None
}
};
}
if has_client_cert && server_has("EXTERNAL") {
return Some(SaslMechanism::External);
}
if has_credentials && server_has("SCRAM-SHA-256") {
return Some(SaslMechanism::ScramSha256);
}
if has_credentials && server_has("PLAIN") {
return Some(SaslMechanism::Plain);
}
None
}
const SASL_TIMEOUT_SECS: u64 = 30;
async fn await_authenticate_plus(stream: &mut irc::client::ClientStream) -> Result<()> {
let result = tokio::time::timeout(std::time::Duration::from_secs(SASL_TIMEOUT_SECS), async {
while let Some(msg_result) = stream.next().await {
let msg = msg_result?;
match &msg.command {
Command::AUTHENTICATE(param) if param == "+" => return Ok(()),
Command::Response(response, _) => match response {
Response::ERR_SASLFAIL => return Err(eyre!("SASL authentication failed")),
Response::ERR_SASLABORT => {
return Err(eyre!("SASL authentication aborted"));
}
Response::ERR_SASLTOOLONG => {
return Err(eyre!("SASL message too long"));
}
_ => {}
},
_ => {}
}
}
Err(eyre!("connection closed waiting for AUTHENTICATE +"))
})
.await;
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e),
Err(_) => Err(eyre!(
"SASL authentication timed out waiting for AUTHENTICATE +"
)),
}
}
pub const MESSAGE_MAX_BYTES: usize = 350;
#[must_use]
pub fn split_irc_message(text: &str, max_bytes: usize) -> Vec<String> {
if text.len() <= max_bytes {
return vec![text.to_string()];
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in WordSplitter::new(text) {
let combined_len = if current.is_empty() {
word.len()
} else {
current.len() + word.len()
};
if combined_len <= max_bytes {
current.push_str(word);
continue;
}
if word.trim().len() <= max_bytes {
if !current.is_empty() {
lines.push(current);
current = String::new();
}
current.push_str(word.trim_start());
continue;
}
for ch in word.chars() {
if current.len() + ch.len_utf8() > max_bytes && !current.is_empty() {
lines.push(current);
current = String::new();
}
current.push(ch);
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
struct WordSplitter<'a> {
remaining: &'a str,
}
impl<'a> WordSplitter<'a> {
const fn new(s: &'a str) -> Self {
Self { remaining: s }
}
}
impl<'a> Iterator for WordSplitter<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<&'a str> {
if self.remaining.is_empty() {
return None;
}
let word_end = self
.remaining
.find(char::is_whitespace)
.unwrap_or(self.remaining.len());
let ws_end = self.remaining[word_end..]
.find(|c: char| !c.is_whitespace())
.map_or(self.remaining.len(), |pos| word_end + pos);
let chunk = &self.remaining[..ws_end];
self.remaining = &self.remaining[ws_end..];
Some(chunk)
}
}
#[expect(clippy::too_many_lines, reason = "IRC config builder with many fields")]
pub async fn connect_server(
conn_id: &str,
server_config: &crate::config::ServerConfig,
general: &crate::config::GeneralConfig,
) -> Result<(IrcHandle, mpsc::Receiver<IrcEvent>)> {
let nick = server_config.nick.as_deref().unwrap_or(&general.nick);
let username = server_config
.username
.as_deref()
.unwrap_or(&general.username);
let realname = server_config
.realname
.as_deref()
.unwrap_or(&general.realname);
let alt_nicks: Vec<String> = (1..=4)
.map(|i| format!("{nick}{}", "_".repeat(i)))
.collect();
let irc_config = Config {
nickname: Some(nick.to_string()),
alt_nicks,
username: Some(username.to_string()),
realname: Some(realname.to_string()),
server: Some(server_config.address.clone()),
port: Some(server_config.port),
use_tls: Some(server_config.tls),
dangerously_accept_invalid_certs: Some(!server_config.tls_verify),
password: server_config.password.clone(),
channels: server_config
.channels
.iter()
.map(|e| {
e.split_once(' ')
.map_or_else(|| e.clone(), |(c, _)| c.to_string())
})
.collect(),
channel_keys: server_config
.channels
.iter()
.filter_map(|e| {
e.split_once(' ')
.map(|(c, k)| (c.to_string(), k.to_string()))
})
.collect(),
encoding: server_config.encoding.clone(),
version: Some(general.ctcp_version.clone()),
client_cert_path: server_config.client_cert_path.clone(),
bind_address: server_config.bind_ip.clone(),
flood_penalty_threshold: Some(if general.flood_protection {
10_000 } else {
0 }),
..Config::default()
};
let mut client = Client::from_config(irc_config).await?;
let local_ip = client.local_addr().map(|a| a.ip());
let sender = client.sender();
let mut stream = client.stream()?;
let outgoing_handle = client.outgoing_handle.take();
let reg_params = RegistrationParams {
nick,
username,
realname,
password: server_config.password.as_deref(),
sasl_user: server_config.sasl_user.as_deref(),
sasl_pass: server_config.sasl_pass.as_deref(),
sasl_mechanism_override: server_config.sasl_mechanism.as_deref(),
has_client_cert: server_config.client_cert_path.is_some(),
};
let neg = negotiate_caps(&sender, &mut stream, ®_params).await?;
let (tx, rx) = mpsc::channel(4096);
let id = conn_id.to_string();
let id2 = id.clone();
tokio::spawn(async move {
let _ = tx
.send(IrcEvent::NegotiationInfo(id.clone(), neg.diagnostics))
.await;
let mut sent_connected = false;
let mut error = None;
for message in neg.early_messages {
if !sent_connected && let Command::Response(Response::RPL_WELCOME, _) = &message.command
{
sent_connected = true;
let _ = tx
.send(IrcEvent::Connected(id.clone(), neg.enabled_caps.clone()))
.await;
}
if tx
.send(IrcEvent::Message(id.clone(), Box::new(message)))
.await
.is_err()
{
return;
}
}
while let Some(result) = stream.next().await {
match result {
Ok(message) => {
if !sent_connected
&& let Command::Response(Response::RPL_WELCOME, _) = &message.command
{
sent_connected = true;
let _ = tx
.send(IrcEvent::Connected(id.clone(), neg.enabled_caps.clone()))
.await;
}
if tx
.send(IrcEvent::Message(id.clone(), Box::new(message)))
.await
.is_err()
{
return;
}
}
Err(e) => {
error = Some(e.to_string());
break;
}
}
}
let _ = tx.send(IrcEvent::Disconnected(id, error)).await;
});
Ok((
IrcHandle {
conn_id: id2,
sender,
local_ip,
outgoing_handle,
},
rx,
))
}
struct RegistrationParams<'a> {
nick: &'a str,
username: &'a str,
realname: &'a str,
password: Option<&'a str>,
sasl_user: Option<&'a str>,
sasl_pass: Option<&'a str>,
sasl_mechanism_override: Option<&'a str>,
has_client_cert: bool,
}
#[expect(clippy::too_many_lines, reason = "single linear negotiation flow")]
async fn negotiate_caps(
sender: &irc::client::Sender,
stream: &mut irc::client::ClientStream,
params: &RegistrationParams<'_>,
) -> Result<NegotiateResult> {
use irc::proto::command::CapSubCommand;
let mut diag: Vec<String> = Vec::new();
let mut early_messages: Vec<irc::proto::Message> = Vec::new();
sender.send(Command::CAP(
None,
CapSubCommand::LS,
Some("302".to_string()),
None,
))?;
if let Some(pass) = params.password {
sender.send(Command::PASS(pass.to_string()))?;
}
sender.send(Command::NICK(params.nick.to_string()))?;
sender.send(Command::USER(
params.username.to_string(),
"0".to_string(),
params.realname.to_string(),
))?;
let mut server_caps = ServerCaps::default();
let mut cap_supported = false;
while let Some(result) = stream.next().await {
let msg = result?;
if let Command::Response(Response::ERR_UNKNOWNCOMMAND, ref args) = msg.command
&& args.iter().any(|a| a.eq_ignore_ascii_case("CAP"))
{
diag.push("CAP: server does not support IRCv3 capabilities".to_string());
break;
}
if let Command::Response(Response::RPL_WELCOME, _) = &msg.command {
diag.push("CAP: server does not support IRCv3 (registered without CAP)".to_string());
early_messages.push(msg);
break;
}
if let Command::CAP(_, CapSubCommand::LS, ref field3, ref field4) = msg.command {
let is_continuation = field3.as_deref() == Some("*");
let caps_str = if is_continuation {
field4.as_deref().unwrap_or("")
} else {
field3.as_deref().unwrap_or("")
};
server_caps.merge(caps_str);
if !is_continuation {
cap_supported = true;
break;
}
}
early_messages.push(msg);
}
let mut enabled_caps: HashSet<String> = HashSet::new();
if cap_supported {
let has_credentials = params.sasl_user.is_some() && params.sasl_pass.is_some();
let server_mechanisms = server_caps.sasl_mechanisms();
let selected_mechanism = select_sasl_mechanism(
&server_mechanisms,
params.sasl_mechanism_override,
params.has_client_cert,
has_credentials,
);
let want_sasl = selected_mechanism.is_some();
diag.push(format!(
"CAP: server advertises sasl={}, has_credentials={has_credentials}, mechanism={:?}",
server_caps.has("sasl"),
selected_mechanism,
));
let mut caps_to_request = server_caps.negotiate(DESIRED_CAPS);
if !want_sasl {
caps_to_request.retain(|c| c != "sasl");
}
let sasl_requested = caps_to_request
.iter()
.position(|c| c == "sasl")
.is_some_and(|pos| {
caps_to_request.remove(pos);
caps_to_request.push("sasl".to_string());
true
});
if caps_to_request.is_empty() {
diag.push("CAP: no capabilities to request".to_string());
} else {
let req_str = caps_to_request.join(" ");
diag.push(format!("CAP REQ: {req_str}"));
sender.send(Command::CAP(None, CapSubCommand::REQ, None, Some(req_str)))?;
while let Some(result) = stream.next().await {
let msg = result?;
if let Command::CAP(_, CapSubCommand::ACK, ref acked, _) = msg.command {
if let Some(ref acked_str) = *acked {
for cap in acked_str.split_whitespace() {
enabled_caps.insert(cap.to_ascii_lowercase());
}
}
diag.push(format!(
"CAP ACK: {}",
enabled_caps.iter().cloned().collect::<Vec<_>>().join(" "),
));
break;
}
if let Command::CAP(_, CapSubCommand::NAK, ref naked, _) = msg.command {
diag.push(format!(
"CAP NAK: {}",
naked.as_deref().unwrap_or("(unknown)"),
));
break;
}
}
}
let sasl_acked = enabled_caps.contains("sasl");
if sasl_requested && sasl_acked {
if let Some(mechanism) = selected_mechanism {
let result = match mechanism {
SaslMechanism::External => {
diag.push("SASL: authenticating via EXTERNAL".to_string());
run_sasl_external(sender, stream).await
}
SaslMechanism::ScramSha256 => {
if let (Some(user), Some(pass)) = (params.sasl_user, params.sasl_pass) {
diag.push(format!("SASL: authenticating via SCRAM-SHA-256 as {user}"));
run_sasl_scram(sender, stream, user, pass).await
} else {
Err(eyre!("SASL SCRAM-SHA-256 selected but credentials missing"))
}
}
SaslMechanism::Plain => {
if let (Some(user), Some(pass)) = (params.sasl_user, params.sasl_pass) {
diag.push(format!("SASL: authenticating via PLAIN as {user}"));
run_sasl_plain(sender, stream, user, pass).await
} else {
Err(eyre!("SASL PLAIN selected but credentials missing"))
}
}
};
match result {
Ok(()) => {
diag.push(format!("SASL: {mechanism} authentication successful"));
}
Err(e) => {
diag.push(format!("SASL: {mechanism} authentication FAILED: {e}"));
enabled_caps.remove("sasl");
}
}
}
} else if sasl_requested && !sasl_acked {
diag.push("SASL: requested but server did not ACK".to_string());
} else if !sasl_requested && has_credentials {
diag.push("SASL: credentials available but server does not advertise sasl".to_string());
}
sender.send(Command::CAP(None, CapSubCommand::END, None, None))?;
}
Ok(NegotiateResult {
enabled_caps,
diagnostics: diag,
early_messages,
})
}
async fn run_sasl_plain(
sender: &irc::client::Sender,
stream: &mut irc::client::ClientStream,
sasl_user: &str,
sasl_pass: &str,
) -> Result<()> {
sender.send(Command::AUTHENTICATE("PLAIN".to_string()))?;
await_authenticate_plus(stream).await?;
let auth_string = format!("{sasl_user}\x00{sasl_user}\x00{sasl_pass}");
let encoded = base64::engine::general_purpose::STANDARD.encode(auth_string);
sender.send(Command::AUTHENTICATE(encoded))?;
while let Some(result) = stream.next().await {
let msg = result?;
if let Command::Response(response, _) = &msg.command {
match response {
Response::RPL_SASLSUCCESS => return Ok(()),
Response::ERR_SASLFAIL => return Err(eyre!("SASL authentication failed")),
Response::ERR_SASLTOOLONG => return Err(eyre!("SASL message too long")),
Response::ERR_SASLABORT => return Err(eyre!("SASL authentication aborted")),
_ => {}
}
}
}
Err(eyre!("SASL authentication: connection closed unexpectedly"))
}
async fn run_sasl_scram(
sender: &irc::client::Sender,
stream: &mut irc::client::ClientStream,
sasl_user: &str,
sasl_pass: &str,
) -> Result<()> {
use base64::Engine as _;
let b64 = &base64::engine::general_purpose::STANDARD;
sender.send(Command::AUTHENTICATE("SCRAM-SHA-256".to_string()))?;
await_authenticate_plus(stream).await?;
let (client_first_bare, client_first_full, client_nonce) = sasl_scram::client_first(sasl_user);
let encoded = b64.encode(&client_first_full);
for chunk in sasl_scram::chunk_authenticate(&encoded) {
sender.send(Command::AUTHENTICATE(chunk))?;
}
let server_first = loop {
if let Some(result) = stream.next().await {
let msg = result?;
match &msg.command {
Command::AUTHENTICATE(param) if param != "+" => {
let decoded = b64
.decode(param)
.map_err(|e| eyre!("SCRAM: invalid base64 in server-first: {e}"))?;
break String::from_utf8(decoded)
.map_err(|e| eyre!("SCRAM: non-UTF-8 server-first: {e}"))?;
}
Command::Response(response, _) => match response {
irc::proto::Response::ERR_SASLFAIL => {
return Err(eyre!("SASL SCRAM-SHA-256 authentication failed"));
}
irc::proto::Response::ERR_SASLABORT => {
return Err(eyre!("SASL SCRAM-SHA-256 authentication aborted"));
}
_ => {}
},
_ => {}
}
} else {
return Err(eyre!(
"SASL SCRAM-SHA-256: connection closed waiting for server-first"
));
}
};
let (client_final_msg, expected_server_sig) =
sasl_scram::client_final(&server_first, &client_first_bare, &client_nonce, sasl_pass)?;
let encoded_final = b64.encode(&client_final_msg);
for chunk in sasl_scram::chunk_authenticate(&encoded_final) {
sender.send(Command::AUTHENTICATE(chunk))?;
}
let mut server_verified = false;
while let Some(result) = stream.next().await {
let msg = result?;
match &msg.command {
Command::AUTHENTICATE(param) if !server_verified && param != "+" => {
let decoded = b64
.decode(param)
.map_err(|e| eyre!("SCRAM: invalid base64 in server-final: {e}"))?;
let server_final = String::from_utf8(decoded)
.map_err(|e| eyre!("SCRAM: non-UTF-8 server-final: {e}"))?;
if !sasl_scram::verify_server(&server_final, &expected_server_sig) {
return Err(eyre!(
"SCRAM: server signature verification failed — possible MITM"
));
}
server_verified = true;
}
Command::Response(response, _) => match response {
irc::proto::Response::RPL_SASLSUCCESS => return Ok(()),
irc::proto::Response::ERR_SASLFAIL => {
return Err(eyre!("SASL SCRAM-SHA-256 authentication failed"));
}
irc::proto::Response::ERR_SASLTOOLONG => {
return Err(eyre!("SASL SCRAM-SHA-256 message too long"));
}
irc::proto::Response::ERR_SASLABORT => {
return Err(eyre!("SASL SCRAM-SHA-256 authentication aborted"));
}
_ => {}
},
_ => {}
}
}
Err(eyre!("SASL SCRAM-SHA-256: connection closed unexpectedly"))
}
async fn run_sasl_external(
sender: &irc::client::Sender,
stream: &mut irc::client::ClientStream,
) -> Result<()> {
sender.send(Command::AUTHENTICATE("EXTERNAL".to_string()))?;
await_authenticate_plus(stream).await?;
sender.send(Command::AUTHENTICATE("+".to_string()))?;
while let Some(result) = stream.next().await {
let msg = result?;
if let Command::Response(response, _) = &msg.command {
match response {
Response::RPL_SASLSUCCESS => return Ok(()),
Response::ERR_SASLFAIL => return Err(eyre!("SASL EXTERNAL authentication failed")),
Response::ERR_SASLTOOLONG => return Err(eyre!("SASL EXTERNAL message too long")),
Response::ERR_SASLABORT => {
return Err(eyre!("SASL EXTERNAL authentication aborted"));
}
_ => {}
}
}
}
Err(eyre!("SASL EXTERNAL: connection closed unexpectedly"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn select_external_when_cert_configured() {
let server_mechs = vec!["PLAIN".to_string(), "EXTERNAL".to_string()];
let result = select_sasl_mechanism(&server_mechs, None, true, true);
assert_eq!(result, Some(SaslMechanism::External));
}
#[test]
fn select_plain_when_only_credentials() {
let server_mechs = vec!["PLAIN".to_string(), "EXTERNAL".to_string()];
let result = select_sasl_mechanism(&server_mechs, None, false, true);
assert_eq!(result, Some(SaslMechanism::Plain));
}
#[test]
fn explicit_override_plain() {
let server_mechs = vec!["PLAIN".to_string(), "EXTERNAL".to_string()];
let result = select_sasl_mechanism(&server_mechs, Some("PLAIN"), true, true);
assert_eq!(result, Some(SaslMechanism::Plain));
}
#[test]
fn explicit_override_external() {
let server_mechs = vec!["PLAIN".to_string(), "EXTERNAL".to_string()];
let result = select_sasl_mechanism(&server_mechs, Some("EXTERNAL"), true, false);
assert_eq!(result, Some(SaslMechanism::External));
}
#[test]
fn explicit_override_unavailable_mechanism() {
let server_mechs = vec!["PLAIN".to_string()];
let result = select_sasl_mechanism(&server_mechs, Some("EXTERNAL"), true, true);
assert_eq!(result, None);
}
#[test]
fn no_credentials_no_cert() {
let server_mechs = vec!["PLAIN".to_string(), "EXTERNAL".to_string()];
let result = select_sasl_mechanism(&server_mechs, None, false, false);
assert_eq!(result, None);
}
#[test]
fn server_no_mechanisms() {
let server_mechs: Vec<String> = vec![];
let result = select_sasl_mechanism(&server_mechs, None, true, true);
assert_eq!(result, None);
}
#[test]
fn external_only_when_server_supports() {
let server_mechs = vec!["PLAIN".to_string()];
let result = select_sasl_mechanism(&server_mechs, None, true, true);
assert_eq!(result, Some(SaslMechanism::Plain));
}
#[test]
fn case_insensitive_override() {
let server_mechs = vec!["PLAIN".to_string(), "EXTERNAL".to_string()];
let result = select_sasl_mechanism(&server_mechs, Some("external"), true, false);
assert_eq!(result, Some(SaslMechanism::External));
}
#[test]
fn sasl_mechanism_display() {
assert_eq!(SaslMechanism::Plain.to_string(), "PLAIN");
assert_eq!(SaslMechanism::External.to_string(), "EXTERNAL");
assert_eq!(SaslMechanism::ScramSha256.to_string(), "SCRAM-SHA-256");
}
#[test]
fn scram_preferred_over_plain() {
let server_mechs = vec!["PLAIN".to_string(), "SCRAM-SHA-256".to_string()];
let result = select_sasl_mechanism(&server_mechs, None, false, true);
assert_eq!(result, Some(SaslMechanism::ScramSha256));
}
#[test]
fn scram_falls_back_to_plain() {
let server_mechs = vec!["PLAIN".to_string()];
let result = select_sasl_mechanism(&server_mechs, None, false, true);
assert_eq!(result, Some(SaslMechanism::Plain));
}
#[test]
fn explicit_override_scram() {
let server_mechs = vec!["PLAIN".to_string(), "SCRAM-SHA-256".to_string()];
let result = select_sasl_mechanism(&server_mechs, Some("SCRAM-SHA-256"), false, true);
assert_eq!(result, Some(SaslMechanism::ScramSha256));
}
#[test]
fn scram_override_unavailable() {
let server_mechs = vec!["PLAIN".to_string()];
let result = select_sasl_mechanism(&server_mechs, Some("SCRAM-SHA-256"), false, true);
assert_eq!(result, None);
}
#[test]
fn external_still_preferred_over_scram() {
let server_mechs = vec![
"PLAIN".to_string(),
"SCRAM-SHA-256".to_string(),
"EXTERNAL".to_string(),
];
let result = select_sasl_mechanism(&server_mechs, None, true, true);
assert_eq!(result, Some(SaslMechanism::External));
}
#[test]
fn short_message_not_split() {
let result = split_irc_message("hello world", 350);
assert_eq!(result, vec!["hello world"]);
}
#[test]
fn split_at_word_boundary() {
let result = split_irc_message("hello world", 10);
assert_eq!(result, vec!["hello ", "world"]);
}
#[test]
fn split_long_text_multiple_chunks() {
let text = "aaa bbb ccc ddd eee fff";
let result = split_irc_message(text, 12);
assert_eq!(result.len(), 2);
for chunk in &result {
assert!(chunk.len() <= 12, "chunk too long: '{chunk}'");
}
}
#[test]
fn very_long_word_split_at_chars() {
let text = "abcdefghij";
let result = split_irc_message(text, 5);
assert_eq!(result, vec!["abcde", "fghij"]);
}
#[test]
fn empty_message() {
let result = split_irc_message("", 350);
assert_eq!(result, vec![""]);
}
#[test]
fn unicode_message_split() {
let text = "🦀🦀🦀";
let result = split_irc_message(text, 8);
assert_eq!(result, vec!["🦀🦀", "🦀"]);
}
#[test]
fn lorem_ipsum_split() {
let text = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, \
sed diam nonumy eirmod tempor invidunt ut labore et dolore \
magna aliquyam erat, sed diam voluptua.";
let result = split_irc_message(text, MESSAGE_MAX_BYTES);
assert_eq!(result.len(), 1);
}
#[test]
fn lorem_ipsum_long_split() {
let text = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, \
sed diam nonumy eirmod tempor invidunt ut labore et dolore \
magna aliquyam erat, sed diam voluptua. At vero eos et accusam \
et justo duo dolores et ea rebum. Stet clita kasd gubergren, \
no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem \
ipsum dolor sit amet, consetetur sadipscing elitr, sed diam \
nonumy eirmod tempor invidunt ut labore et dolore magna \
aliquyam erat, sed diam voluptua.";
let result = split_irc_message(text, MESSAGE_MAX_BYTES);
assert!(
result.len() >= 2,
"expected multiple chunks, got {}",
result.len()
);
for (i, chunk) in result.iter().enumerate() {
assert!(
chunk.len() <= MESSAGE_MAX_BYTES,
"chunk {i} is {} bytes (max {MESSAGE_MAX_BYTES}): '{chunk}'",
chunk.len(),
);
}
let reassembled: String = result.join(" ");
let orig_words: Vec<&str> = text.split_whitespace().collect();
let re_words: Vec<&str> = reassembled.split_whitespace().collect();
assert_eq!(orig_words, re_words);
}
}