irkki-core 0.1.1

The irkki core library.
Documentation
use log::info;
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::net::TcpStream;
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};

use crate::{Message, Parser};

#[derive(PartialEq)]
pub enum IRCEvent {
    Message(Message),
    Users(Vec<String>),
    MessageOfTheDay(Vec<String>),
    Raw(String),
}

impl std::fmt::Debug for IRCEvent {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            IRCEvent::Message(msg) => write!(f, "IRCEvent::Message(cmd: {})", msg.command),
            IRCEvent::Raw(s) => write!(f, "IRCEvent::Raw({})", s),
            IRCEvent::Users(users) => write!(f, "IRCEvent::Users({})", users.join(", ")),
            IRCEvent::MessageOfTheDay(motd) => {
                write!(f, "IRCEvent::MessageOfTheDay({})", motd.join("\n"))
            }
        }
    }
}

pub struct IRCClient {
    nickname: String,
    server: String,
    port: u16,
    channel: String,
    reader: Option<BufReader<TcpStream>>,
    writer: Option<Arc<Mutex<BufWriter<TcpStream>>>>,
}

impl IRCClient {
    pub fn connect(
        nickname: impl Into<String>,
        server: impl Into<String>,
        port: u16,
    ) -> io::Result<Self> {
        let mut client = Self::new(nickname, server, port);
        client.connect_inner()?;
        Ok(client)
    }

    fn new(nickname: impl Into<String>, server: impl Into<String>, port: u16) -> Self {
        Self {
            nickname: nickname.into(),
            server: server.into(),
            port,
            channel: "#testchannel".to_string(),
            reader: None,
            writer: None,
        }
    }

    fn connect_inner(&mut self) -> io::Result<()> {
        let stream = TcpStream::connect((self.server.as_str(), self.port))?;
        let reader = BufReader::new(stream.try_clone()?);
        let writer = Arc::new(Mutex::new(BufWriter::new(stream)));

        self.reader = Some(reader);
        self.writer = Some(writer);

        self.send_line(&format!("NICK {}", self.nickname))?;
        self.send_line(&format!("USER {} 0 * :{}", self.nickname, self.nickname))?;
        self.send_line(&format!("JOIN {}", self.channel))?;

        Ok(())
    }

    pub fn quit(&mut self) -> io::Result<()> {
        if self.writer.is_none() {
            return Ok(());
        }

        self.send_line(&format!("PART {} :Goodbye!", self.channel))?;
        info!("Sent PART command for channel {}", self.channel);
        self.send_line("QUIT :Client closed")?;
        info!("Sent QUIT command");

        Ok(())
    }

    pub fn whois(&mut self, nickname: impl AsRef<str>) -> io::Result<()> {
        let nickname = nickname.as_ref().trim();
        if nickname.is_empty() {
            return Ok(());
        }

        info!("Sending WHOIS command for nickname: {}", nickname);
        self.send_line(&format!("WHOIS {}", nickname))
    }

    pub fn send_message(&mut self, message: impl AsRef<str>) -> io::Result<()> {
        let message = message.as_ref().trim();
        if message.is_empty() {
            return Ok(());
        }

        info!(
            "Sending PRIVMSG command to channel {}: {}",
            self.channel, message
        );
        self.send_line(&format!("PRIVMSG {} :{}", self.channel, message))
    }

    pub fn start_listening<F>(&mut self, mut message_handler: F) -> io::Result<JoinHandle<()>>
    where
        F: FnMut(IRCEvent) -> io::Result<()> + Send + 'static,
    {
        let mut reader = self.reader.take().ok_or_else(|| {
            io::Error::new(io::ErrorKind::NotConnected, "Client is not connected.")
        })?;
        let writer = self.writer.clone().ok_or_else(|| {
            io::Error::new(io::ErrorKind::NotConnected, "Client is not connected.")
        })?;

        Ok(thread::spawn(move || {
            let _ = Self::listen_loop(&mut reader, writer, &mut message_handler);
        }))
    }

    pub fn listen<F>(&mut self, mut message_handler: F) -> io::Result<()>
    where
        F: FnMut(IRCEvent) -> io::Result<()>,
    {
        let writer = self.writer.clone().ok_or_else(|| {
            io::Error::new(io::ErrorKind::NotConnected, "Client is not connected.")
        })?;
        let reader = self.reader.as_mut().ok_or_else(|| {
            io::Error::new(io::ErrorKind::NotConnected, "Client is not connected.")
        })?;
        Self::listen_loop(reader, writer, &mut message_handler)
    }

    fn listen_loop<F>(
        reader: &mut BufReader<TcpStream>,
        writer: Arc<Mutex<BufWriter<TcpStream>>>,
        message_handler: &mut F,
    ) -> io::Result<()>
    where
        F: FnMut(IRCEvent) -> io::Result<()>,
    {
        let mut message_of_the_day = Vec::new();
        loop {
            let mut line = String::new();
            let read_result = reader.read_line(&mut line);

            match read_result {
                Ok(0) => break,
                Ok(_) => {
                    info!("Received line: {}", line.trim_end());

                    let mut parser = Parser::new(&line);
                    let message = parser.parse_message();

                    match message.command.as_str() {
                        "PING" => {
                            let response = format!("PONG :{}", message.params.join(" "));
                            Self::send_line_with_writer(&writer, &response)?;
                            continue;
                        }
                        "353" => {
                            if let Some(names) = message.params.last() {
                                let mut users: Vec<String> = Vec::new();
                                for nick in names.split_whitespace() {
                                    if !nick.is_empty() {
                                        users.push(nick.to_string());
                                    }
                                }
                                message_handler(IRCEvent::Users(users))?;
                            }
                        }
                        "366" => {
                            info!("End of NAMES list.");
                        }
                        "375" => {
                            message_of_the_day.clear();
                        }
                        "372" => {
                            if let Some(motd_line) = message.params.last() {
                                message_of_the_day.push(motd_line.to_string());
                            }
                        }
                        "376" => {
                            message_handler(IRCEvent::MessageOfTheDay(message_of_the_day.clone()))?;
                        }
                        _ => {
                            message_handler(IRCEvent::Message(message))?;
                        }
                    }
                }
                Err(_) => break,
            }
        }

        Ok(())
    }

    fn send_line(&mut self, line: &str) -> io::Result<()> {
        let writer = self.writer.as_ref().ok_or_else(|| {
            io::Error::new(io::ErrorKind::NotConnected, "Client is not connected.")
        })?;
        Self::send_line_with_writer(writer, line)
    }

    fn send_line_with_writer(
        writer: &Arc<Mutex<BufWriter<TcpStream>>>,
        line: &str,
    ) -> io::Result<()> {
        let mut writer = writer
            .lock()
            .map_err(|_| io::Error::other("Writer lock poisoned"))?;

        writer.write_all(line.as_bytes())?;
        writer.write_all(b"\r\n")?;
        writer.flush()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn quit_without_connection_is_ok() {
        let mut client = IRCClient::new("nick", "localhost", 6667);

        assert!(client.quit().is_ok());
    }

    #[test]
    fn irc_event_debug_formats_message_variant() {
        let event = IRCEvent::Message(Message {
            prefix: None,
            command: "NOTICE".to_string(),
            params: vec!["#test".to_string(), "hello".to_string()],
        });

        assert_eq!(format!("{event:?}"), "IRCEvent::Message(cmd: NOTICE)");
    }

    #[test]
    fn irc_event_debug_formats_users_variant() {
        let event = IRCEvent::Users(vec!["alice".to_string(), "bob".to_string()]);

        assert_eq!(format!("{event:?}"), "IRCEvent::Users(alice, bob)");
    }

    #[test]
    fn irc_event_debug_formats_raw_variant() {
        let event = IRCEvent::Raw("Connected".to_string());

        assert_eq!(format!("{event:?}"), "IRCEvent::Raw(Connected)");
    }
}