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:
parent
b9e95430f5
commit
a15fb4f9a9
16 changed files with 1189 additions and 1116 deletions
313
src/main.rs
313
src/main.rs
|
|
@ -1,29 +1,17 @@
|
|||
//! rustbot — a small, modular IRC bot.
|
||||
//!
|
||||
//! Layers:
|
||||
//! * `irc` — protocol + transport (the "IRC library")
|
||||
//! * `bot` — behavior: registration, event loop, commands
|
||||
//! * `main` — configuration and the connect/reconnect supervisor
|
||||
//!
|
||||
//! Configuration is layered (see [`load_config`]): built-in defaults, then a
|
||||
//! `key = value` config file, then environment variables — so the same binary
|
||||
//! can target any network without a rebuild. The file may define several
|
||||
//! `[network]` sections, in which case the bot connects to all of them at once,
|
||||
//! one supervisor thread each.
|
||||
//! rustbot binary — a per-network connect/reconnect supervisor built on the
|
||||
//! `rustbot` library. All the real logic lives in the library
|
||||
//! (`config`, `irc`, `bot`); this file only spawns and keeps sessions alive.
|
||||
|
||||
mod bot;
|
||||
mod irc;
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use bot::{Bot, Config};
|
||||
use irc::Connection;
|
||||
use rustbot::bot::Bot;
|
||||
use rustbot::config::{self, Config};
|
||||
use rustbot::irc::Connection;
|
||||
use rustbot::log;
|
||||
|
||||
fn main() {
|
||||
let configs = load_config();
|
||||
let configs = config::load();
|
||||
if configs.is_empty() {
|
||||
log("no networks configured; nothing to do");
|
||||
std::process::exit(1);
|
||||
|
|
@ -93,288 +81,3 @@ fn run_once(config: &Config, labelled: bool) -> std::io::Result<()> {
|
|||
let mut bot = Bot::new(config.clone(), conn);
|
||||
bot.run()
|
||||
}
|
||||
|
||||
/// Build one [`Config`] per network by layering sources.
|
||||
///
|
||||
/// The config file is INI-flavoured: `key = value` lines, optionally grouped
|
||||
/// under `[network]` section headers. Keys *before* the first header are shared
|
||||
/// defaults; each `[section]` defines one network that inherits those defaults
|
||||
/// and overrides them. A file with **no** sections yields a single network —
|
||||
/// exactly the historical behaviour.
|
||||
///
|
||||
/// Precedence within a network: built-in defaults < shared file defaults <
|
||||
/// environment variables < that network's section.
|
||||
fn load_config() -> Vec<Config> {
|
||||
let path = config_path();
|
||||
let content = path.as_ref().and_then(|p| match fs::read_to_string(p) {
|
||||
Ok(s) => {
|
||||
log(&format!("loaded config from {}", p.display()));
|
||||
Some(s)
|
||||
}
|
||||
Err(e) => {
|
||||
log(&format!("could not read config {}: {e}", p.display()));
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let (toplevel, sections) = match &content {
|
||||
Some(text) => parse_sections(text),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
|
||||
// Shared defaults: built-ins, then top-level keys, then environment. Each
|
||||
// network is derived from this base.
|
||||
let mut base = Config::default();
|
||||
for (line, key, value) in &toplevel {
|
||||
apply_kv(&mut base, key, value, &loc(&path, *line));
|
||||
}
|
||||
apply_env(&mut base);
|
||||
|
||||
assemble(base, sections, &path)
|
||||
}
|
||||
|
||||
/// Derive one finished [`Config`] per network from the shared `base` and the
|
||||
/// parsed sections. With no sections, `base` itself is the single network.
|
||||
/// `path` is used only to describe locations in warnings.
|
||||
fn assemble(base: Config, sections: Vec<(String, Vec<Entry>)>, path: &Option<PathBuf>) -> Vec<Config> {
|
||||
let mut configs = Vec::new();
|
||||
if sections.is_empty() {
|
||||
let mut c = base;
|
||||
finalize(&mut c);
|
||||
configs.push(c);
|
||||
} else {
|
||||
for (name, entries) in sections {
|
||||
let mut c = base.clone();
|
||||
c.name = name;
|
||||
for (line, key, value) in &entries {
|
||||
apply_kv(&mut c, key, value, &loc(path, *line));
|
||||
}
|
||||
finalize(&mut c);
|
||||
configs.push(c);
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
|
||||
/// Per-network finishing touches: default the label to the server host, and
|
||||
/// honour the TLS-port convention (6697) when TLS is on but the port is still
|
||||
/// the plaintext default.
|
||||
fn finalize(c: &mut Config) {
|
||||
if c.name.is_empty() {
|
||||
c.name = c.server.clone();
|
||||
}
|
||||
if c.tls && c.port == 6667 {
|
||||
c.port = 6697;
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `key = value` pair with its 1-based source line, for diagnostics.
|
||||
type Entry = (usize, String, String);
|
||||
|
||||
/// Split config text into shared top-level entries and `[name]` sections.
|
||||
///
|
||||
/// Blank lines and whole-line `#`/`;` comments are ignored. A `[name]` line
|
||||
/// opens a section; entries after it belong to that network until the next
|
||||
/// header. Lines that are neither a header nor `key = value` are reported and
|
||||
/// skipped.
|
||||
fn parse_sections(content: &str) -> (Vec<Entry>, Vec<(String, Vec<Entry>)>) {
|
||||
let mut toplevel: Vec<Entry> = Vec::new();
|
||||
let mut sections: Vec<(String, Vec<Entry>)> = Vec::new();
|
||||
|
||||
for (i, raw) in content.lines().enumerate() {
|
||||
let line = raw.trim();
|
||||
let lineno = i + 1;
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
// Section header: `[name]`.
|
||||
if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||
sections.push((inner.trim().to_string(), Vec::new()));
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
log(&format!("line {lineno}: ignoring line without '='"));
|
||||
continue;
|
||||
};
|
||||
let entry = (lineno, key.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||
match sections.last_mut() {
|
||||
Some((_, entries)) => entries.push(entry),
|
||||
None => toplevel.push(entry),
|
||||
}
|
||||
}
|
||||
(toplevel, sections)
|
||||
}
|
||||
|
||||
/// Apply a single `key = value` pair onto `c`. `where_` describes the source
|
||||
/// location for warnings (e.g. `rustbot.conf:12`).
|
||||
fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
|
||||
match key {
|
||||
"name" => c.name = value.to_string(),
|
||||
"server" => c.server = value.to_string(),
|
||||
"port" => match value.parse() {
|
||||
Ok(p) => c.port = p,
|
||||
Err(_) => log(&format!("{where_}: invalid port '{value}'")),
|
||||
},
|
||||
"tls" => c.tls = parse_bool(value),
|
||||
"nick" => c.nick = value.to_string(),
|
||||
"user" => c.user = value.to_string(),
|
||||
"realname" => c.realname = value.to_string(),
|
||||
"channels" => c.channels = split_list(value),
|
||||
"prefix" | "command_prefix" => c.command_prefix = value.to_string(),
|
||||
"password" => c.password = (!value.is_empty()).then(|| value.to_string()),
|
||||
"sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()),
|
||||
"sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()),
|
||||
other => log(&format!("{where_}: unknown key '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a source location for a top-level line, including the file path.
|
||||
fn loc(path: &Option<PathBuf>, line: usize) -> String {
|
||||
match path {
|
||||
Some(p) => format!("{}:{}", p.display(), line),
|
||||
None => format!("line {line}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which config file to read: CLI argument, then `RUSTBOT_CONFIG`, then
|
||||
/// `./rustbot.conf` if it happens to exist. Returns `None` when there is none.
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
if let Some(arg) = std::env::args().nth(1) {
|
||||
return Some(PathBuf::from(arg));
|
||||
}
|
||||
if let Ok(env) = std::env::var("RUSTBOT_CONFIG") {
|
||||
return Some(PathBuf::from(env));
|
||||
}
|
||||
let default = PathBuf::from("rustbot.conf");
|
||||
default.exists().then_some(default)
|
||||
}
|
||||
|
||||
/// Overlay environment variables onto `c` (applied to the shared defaults, so
|
||||
/// they affect every network unless a `[section]` overrides them).
|
||||
///
|
||||
/// Vars: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, `IRC_REALNAME`,
|
||||
/// `IRC_CHANNELS` (comma/space-separated), `IRC_PREFIX`, `IRC_PASSWORD`,
|
||||
/// `IRC_SASL_USER`, `IRC_SASL_PASS`.
|
||||
fn apply_env(c: &mut Config) {
|
||||
if let Ok(v) = std::env::var("IRC_SERVER") {
|
||||
c.server = v;
|
||||
}
|
||||
if let Some(p) = std::env::var("IRC_PORT").ok().and_then(|v| v.parse().ok()) {
|
||||
c.port = p;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_TLS") {
|
||||
c.tls = parse_bool(&v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_NICK") {
|
||||
c.user = v.clone();
|
||||
c.nick = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_REALNAME") {
|
||||
c.realname = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_CHANNELS") {
|
||||
c.channels = split_list(&v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_PREFIX") {
|
||||
c.command_prefix = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_PASSWORD") {
|
||||
c.password = Some(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_SASL_USER") {
|
||||
c.sasl_user = Some(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_SASL_PASS") {
|
||||
c.sasl_pass = Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a boolean config value. Accepts `1`, `true`, `yes`, `on` (any case).
|
||||
fn parse_bool(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
/// Split a channel list on commas and/or whitespace, dropping empties.
|
||||
fn split_list(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Tiny stderr logger. Swap for the `log`/`tracing` crates if you outgrow it.
|
||||
fn log(msg: &str) {
|
||||
eprintln!("[rustbot] {msg}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build the network list from config text, mirroring [`load_config`] but
|
||||
/// without touching the filesystem or environment (so tests are hermetic).
|
||||
fn build(content: &str) -> Vec<Config> {
|
||||
let (toplevel, sections) = parse_sections(content);
|
||||
let mut base = Config::default();
|
||||
for (line, key, value) in &toplevel {
|
||||
apply_kv(&mut base, key, value, &format!("line {line}"));
|
||||
}
|
||||
assemble(base, sections, &None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_config_is_a_single_network() {
|
||||
let cfgs = build("server = irc.example.org\nnick = flatbot\nchannels = #a #b\n");
|
||||
assert_eq!(cfgs.len(), 1);
|
||||
assert_eq!(cfgs[0].server, "irc.example.org");
|
||||
assert_eq!(cfgs[0].nick, "flatbot");
|
||||
assert_eq!(cfgs[0].channels, vec!["#a", "#b"]);
|
||||
// The label defaults to the server host.
|
||||
assert_eq!(cfgs[0].name, "irc.example.org");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_inherit_top_level_defaults_and_override() {
|
||||
let cfgs = build(
|
||||
"nick = shared\nchannels = #lobby\n\
|
||||
[libera]\nserver = irc.libera.chat\ntls = true\n\
|
||||
[oftc]\nserver = irc.oftc.net\nnick = other\nchannels = #oftc\n",
|
||||
);
|
||||
assert_eq!(cfgs.len(), 2);
|
||||
|
||||
let libera = &cfgs[0];
|
||||
assert_eq!(libera.name, "libera");
|
||||
assert_eq!(libera.server, "irc.libera.chat");
|
||||
assert_eq!(libera.nick, "shared"); // inherited
|
||||
assert_eq!(libera.channels, vec!["#lobby"]); // inherited
|
||||
assert!(libera.tls);
|
||||
assert_eq!(libera.port, 6697); // TLS-port convention applied per network
|
||||
|
||||
let oftc = &cfgs[1];
|
||||
assert_eq!(oftc.name, "oftc");
|
||||
assert_eq!(oftc.server, "irc.oftc.net");
|
||||
assert_eq!(oftc.nick, "other"); // overridden
|
||||
assert_eq!(oftc.channels, vec!["#oftc"]); // overridden
|
||||
assert!(!oftc.tls); // default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_may_rename_itself() {
|
||||
let cfgs = build("[net]\nname = pretty\nserver = irc.example.org\n");
|
||||
assert_eq!(cfgs.len(), 1);
|
||||
assert_eq!(cfgs[0].name, "pretty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_blanks_are_ignored() {
|
||||
let (toplevel, sections) = parse_sections("# comment\n; also\n\n \nserver = x\n");
|
||||
assert!(sections.is_empty());
|
||||
assert_eq!(toplevel.len(), 1);
|
||||
assert_eq!(toplevel[0].1, "server");
|
||||
assert_eq!(toplevel[0].2, "x");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue