import echoircd — from-scratch irc daemon in native rust

This commit is contained in:
Jean Chevronnet 2026-08-05 16:10:31 +00:00
commit 9b12791774
38 changed files with 9757 additions and 0 deletions

124
src/message.rs Normal file
View file

@ -0,0 +1,124 @@
//! IRC line parsing (RFC 1459 + IRCv3 message tags). One line in → an optional
//! [`Message`] out. Client-only tags (the `+`-prefixed ones) are captured so
//! TAGMSG / PRIVMSG can relay them onward; server tags from clients are dropped.
#[derive(Debug, PartialEq)]
pub struct Message {
/// The `:source` prefix, if any (clients rarely send one).
pub source: Option<String>,
/// Command, upper-cased (`PRIVMSG`, `JOIN`, …) or a 3-digit numeric.
pub command: String,
/// Parameters, with the trailing `:param` unwrapped into the last element.
pub params: Vec<String>,
/// Client-only IRCv3 tags (`+key=val;…`) re-serialised for relay; `""` if none.
pub ctags: String,
}
/// Parse one wire line. Returns `None` for an empty/garbage line.
pub fn parse(line: &str) -> Option<Message> {
let mut rest = line.trim_start();
// IRCv3 message tags — keep the client-only (`+`) tags for relay, drop the rest.
let mut ctags = String::new();
if let Some(after_at) = rest.strip_prefix('@') {
let (tags, r) = after_at.split_once(' ')?;
ctags = tags
.split(';')
.filter(|t| t.starts_with('+'))
.collect::<Vec<_>>()
.join(";");
rest = r.trim_start();
}
let mut source = None;
if let Some(after_colon) = rest.strip_prefix(':') {
let (src, r) = after_colon.split_once(' ')?;
source = Some(src.to_string());
rest = r.trim_start();
}
let (cmd, mut rest) = match rest.split_once(' ') {
Some((c, r)) => (c, r.trim_start()),
None => (rest, ""),
};
if cmd.is_empty() {
return None;
}
let mut params = Vec::new();
while !rest.is_empty() {
if let Some(trailing) = rest.strip_prefix(':') {
params.push(trailing.to_string());
break;
}
match rest.split_once(' ') {
Some((p, r)) => {
params.push(p.to_string());
rest = r.trim_start();
}
None => {
params.push(rest.to_string());
break;
}
}
}
Some(Message {
source,
command: cmd.to_ascii_uppercase(),
params,
ctags,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_command() {
let m = parse("NICK reverse").unwrap();
assert_eq!(m.command, "NICK");
assert_eq!(m.params, vec!["reverse"]);
assert!(m.source.is_none());
}
#[test]
fn trailing_keeps_spaces() {
let m = parse("PRIVMSG #argentina :hola que tal").unwrap();
assert_eq!(m.command, "PRIVMSG");
assert_eq!(m.params, vec!["#argentina", "hola que tal"]);
}
#[test]
fn source_and_lowercase_command_upcased() {
let m = parse(":nick!u@h privmsg x :y").unwrap();
assert_eq!(m.source.as_deref(), Some("nick!u@h"));
assert_eq!(m.command, "PRIVMSG");
assert_eq!(m.params, vec!["x", "y"]);
}
#[test]
fn tags_are_skipped() {
let m = parse("@id=1;time=x PING :token").unwrap();
assert_eq!(m.command, "PING");
assert_eq!(m.params, vec!["token"]);
assert_eq!(m.ctags, ""); // no client-only tags here
}
#[test]
fn client_only_tags_kept_for_relay() {
let m = parse("@time=x;+typing=done;account=z TAGMSG #devs").unwrap();
assert_eq!(m.command, "TAGMSG");
assert_eq!(m.params, vec!["#devs"]);
assert_eq!(m.ctags, "+typing=done"); // server tags dropped, `+` kept
}
#[test]
fn empty_and_junk() {
assert!(parse("").is_none());
assert!(parse(" ").is_none());
// a lone colon prefix with nothing after is not a message
assert!(parse(":only").is_none());
}
}