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

275
src/config.rs Normal file
View file

@ -0,0 +1,275 @@
//! Configuration: the [`Config`] one network needs, and how a config file
//! (with optional `[network]` sections) plus environment variables are layered
//! into one [`Config`] per network.
use std::fs;
use std::path::PathBuf;
use crate::log;
/// Everything the bot needs to know to connect to one network and behave.
#[derive(Debug, Clone)]
pub struct Config {
/// Short label for this network, used in logs and as the thread name. Set
/// via an INI-style `[section]` header (or a `name =` key); defaults to the
/// server host. Only one network? It stays out of the logs entirely.
pub name: String,
pub server: String,
pub port: u16,
/// Connect over TLS (typically port 6697) instead of plaintext.
pub tls: bool,
pub nick: String,
pub user: String,
pub realname: String,
pub channels: Vec<String>,
/// Prefix that marks a chat message as a bot command, e.g. `!`.
pub command_prefix: String,
/// Optional server password (`PASS`). Not the same as SASL/NickServ.
pub password: Option<String>,
/// SASL PLAIN credentials. When both are set (and the server offers `sasl`),
/// the bot authenticates during capability negotiation.
pub sasl_user: Option<String>,
pub sasl_pass: Option<String>,
}
impl Default for Config {
fn default() -> Config {
Config {
name: String::new(),
server: "irc.tchatou.fr".to_string(),
port: 6667,
tls: false,
nick: "rubot".to_string(),
user: "rubot".to_string(),
realname: "rubot".to_string(),
channels: vec!["#devs".to_string()],
command_prefix: "!".to_string(),
password: None,
sasl_user: None,
sasl_pass: None,
}
}
}
/// A parsed `key = value` pair with its 1-based source line, for diagnostics.
type Entry = (usize, String, String);
/// 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.
pub fn load() -> 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)
}
/// Parse networks from config *text* alone — no file access, no environment.
/// Handy for tests and for embedding rustbot. Warnings reference `line N`.
pub fn parse_str(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)
}
/// 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;
}
}
/// 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 line, including the file path when known.
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()
}