Restructure into a library crate with modular bot and irc

Split the single-binary crate into a reusable `rustbot` library (src/lib.rs)
plus a thin supervisor binary. The monolithic irc.rs and bot.rs become module
folders:

  irc/    message (wire parsing), connection (TCP/TLS transport), encoding
  bot/    mod (state + event loop), caps (IRCv3 cap negotiation + SASL),
          commands (message routing + command table)
  config  Config + layered loading, moved out of main.rs and bot.rs

main.rs is now just the per-network connect/reconnect supervisor over the
library API. Unit tests move out of the source files into tests/ integration
tests (config.rs, protocol.rs) that drive the public surface — prefix parsing
is now covered through Message::parse, and config layering through the new
public config::parse_str. Behavior is unchanged; no new dependencies.
This commit is contained in:
Jean Chevronnet 2026-07-29 15:38:55 +00:00
parent b9e95430f5
commit a15fb4f9a9
16 changed files with 1189 additions and 1116 deletions

80
tests/protocol.rs Normal file
View file

@ -0,0 +1,80 @@
//! Integration tests for the IRC protocol layer, exercising the public
//! `rustbot::irc` API: message parsing, IRCv3 tags, base64, and server-time.
use rustbot::irc::{base64_encode, parse_server_time, Message};
#[test]
fn parses_privmsg_with_prefix() {
let m = Message::parse(":alice!a@host PRIVMSG #chan :hi there\r\n").unwrap();
assert_eq!(m.command, "PRIVMSG");
assert_eq!(m.nick(), Some("alice"));
assert_eq!(m.param(0), Some("#chan"));
assert_eq!(m.trailing(), Some("hi there"));
}
#[test]
fn parses_ping_without_prefix() {
let m = Message::parse("PING :LAG1234").unwrap();
assert_eq!(m.command, "PING");
assert!(m.prefix.is_none());
assert_eq!(m.trailing(), Some("LAG1234"));
}
#[test]
fn parses_and_unescapes_tags() {
let m = Message::parse("@id=123;key=a\\sb\\:c :n!u@h PRIVMSG #c :yo").unwrap();
assert_eq!(m.tag("id"), Some("123"));
assert_eq!(m.tag("key"), Some("a b;c"));
assert_eq!(m.command, "PRIVMSG");
assert_eq!(m.trailing(), Some("yo"));
}
#[test]
fn parses_numeric_reply() {
let m = Message::parse(":srv 001 me :Welcome to the network").unwrap();
assert_eq!(m.command, "001");
assert_eq!(m.param(0), Some("me"));
assert_eq!(m.trailing(), Some("Welcome to the network"));
}
#[test]
fn full_prefix_is_split() {
// Prefix parsing is exercised through the public `Message::parse`.
let m = Message::parse(":nick!user@host.example PRIVMSG #c :hi").unwrap();
let p = m.prefix.expect("prefix present");
assert_eq!(p.nick.as_deref(), Some("nick"));
assert_eq!(p.user.as_deref(), Some("user"));
assert_eq!(p.host.as_deref(), Some("host.example"));
assert_eq!(p.name(), "nick");
}
#[test]
fn blank_line_is_none() {
assert!(Message::parse(" \r\n").is_none());
}
#[test]
fn base64_matches_known_vectors() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
// A SASL PLAIN payload: authzid="" authcid="me" passwd="pw"
assert_eq!(base64_encode(b"\0me\0pw"), "AG1lAHB3");
}
#[test]
fn server_time_parses_to_unix_seconds() {
assert_eq!(parse_server_time("1970-01-01T00:00:00.000Z"), Some(0));
assert_eq!(parse_server_time("2020-01-01T00:00:00Z"), Some(1_577_836_800));
assert_eq!(parse_server_time("2021-01-01T00:00:00.123Z"), Some(1_609_459_200));
assert_eq!(parse_server_time("not-a-timestamp"), None);
}
#[test]
fn reads_message_tags_via_accessor() {
let m = Message::parse("@time=2021-01-01T00:00:00Z;batch=42 :s PRIVMSG #c :hi").unwrap();
assert_eq!(m.tag("batch"), Some("42"));
assert_eq!(m.tag("time"), Some("2021-01-01T00:00:00Z"));
assert_eq!(m.tag("missing"), None);
}