use crate::rules::{Rule, RuleDef};
#[derive(Debug, Clone)]
pub struct Module {
pub name: &'static str,
pub description: &'static str,
pub rules: Vec<RuleDef>,
}
impl Module {
pub(crate) fn new(
name: &'static str,
description: &'static str,
rules: Vec<RuleDef>,
) -> Module {
Module {
name,
description,
rules,
}
}
}
pub fn all() -> Vec<Module> {
vec![
syslog(),
httpd(),
squid(),
]
.into_iter()
.chain(extra_modules())
.chain(crate::modules_modern::all())
.collect()
}
pub fn detect(sample: &[String]) -> Vec<&'static str> {
let mut detected = Vec::new();
for m in all() {
let signatures: Vec<Rule> = m
.rules
.iter()
.filter(|d| d.pattern.starts_with('^'))
.filter_map(|d| Rule::compile(d).ok())
.filter(|r| r.has_named_groups)
.collect();
if signatures.is_empty() {
continue;
}
let present = sample
.iter()
.any(|line| signatures.iter().any(|r| r.regex.is_match(line)));
if present {
detected.push(m.name);
}
}
detected
}
pub fn get(name: &str) -> Option<Module> {
all()
.into_iter()
.find(|m| m.name.eq_ignore_ascii_case(name))
}
pub fn resolve(names: &[String]) -> Result<Vec<RuleDef>, Vec<String>> {
let mut out = Vec::new();
let mut unknown = Vec::new();
for name in names {
if name.eq_ignore_ascii_case("all") {
for m in all() {
out.extend(m.rules);
}
continue;
}
match get(name) {
Some(m) => out.extend(m.rules),
None => unknown.push(name.clone()),
}
}
if unknown.is_empty() {
Ok(out)
} else {
Err(unknown)
}
}
fn syslog() -> Module {
Module::new(
"syslog",
"Generic syslog(8) log coloriser",
vec![
RuleDef::new(
"syslog-line",
r"^(?P<date>\S+\s{1,2}\d{1,2}\s\d\d:\d\d:\d\d)\s(?P<host>\S+)\s+(?P<process>[\w./-]+)(?:\[(?P<pid>\d+)\])?:",
),
RuleDef::with_token(
"syslog-repeat",
r"(?:last message repeated \d+ times|-- MARK --)",
crate::theme::tokens::REPEAT,
),
],
)
}
fn httpd() -> Module {
use crate::theme::tokens::*;
Module::new(
"httpd",
"Coloriser for generic HTTPD access and error logs",
vec![
RuleDef::new(
"httpd-access",
r#"^(?P<host>\S+)\s+(?:\S+\s+)?-\s+(?P<user>\S+)\s+(?P<date>\[[^\]]+\])\s+"(?P<http_method>[A-Z]+)[^"]*"\s+(?P<http_code>\d{3})\s+(?P<getsize>\d+|-)"#,
),
RuleDef::new(
"httpd-error",
r"^(?P<date>\[\w{3}\s\w{3}\s{1,2}\d{1,2}\s\d{2}:\d{2}:\d{2}\s\d{4}\])\s+(?P<level>\[\w+\])",
),
RuleDef::with_token("httpd-lvl-error", r"\[(?:error|crit|alert|emerg)\]", ERROR).ci(),
RuleDef::with_token("httpd-lvl-warn", r"\[warn\]", WARNING).ci(),
RuleDef::with_token("httpd-lvl-debug", r"\[(?:debug|info|notice)\]", DEBUG).ci(),
],
)
}
fn squid() -> Module {
use crate::theme::tokens::*;
Module::new(
"squid",
"Coloriser for squid access, store and cache logs",
vec![
RuleDef::new(
"squid-access",
r"^(?P<time>\d{9,10}\.\d{3})\s+(?P<gettime>\d+)\s(?P<host>\S+)\s\w+/(?P<http_code>\d{3})\s(?P<getsize>\d+)\s(?P<http_method>\w+)\s(?P<uri>\S+)\s(?P<ident>\S+)\s\w+/(?:[\d.]+|-)\s(?P<ctype>\S+)",
),
RuleDef::new(
"squid-cache",
r"^(?P<date>\d{4}/\d{2}/\d{2}\s(?:\d{2}:){2}\d{2})\|",
),
RuleDef::with_token("squid-hit", r"\b\w*HIT\w*\b", PROXY_HIT),
RuleDef::with_token("squid-miss", r"\b\w*MISS\w*\b", PROXY_MISS),
RuleDef::with_token("squid-denied", r"\b\w*DENIED\w*\b", PROXY_DENIED),
RuleDef::with_token("squid-refresh", r"\b\w*REFRESH\w*\b", PROXY_REFRESH),
RuleDef::with_token("squid-swapfail", r"\b\w*SWAPFAIL\w*\b", PROXY_SWAPFAIL),
RuleDef::with_token("squid-direct", r"\bDIRECT\b", PROXY_DIRECT),
RuleDef::with_token("squid-parent", r"\bPARENT\b", PROXY_PARENT),
RuleDef::with_token("squid-err", r"\bERR_\w+\b", ERROR),
],
)
}
fn extra_modules() -> Vec<Module> {
vec![
dpkg(),
postfix(),
exim(),
procmail(),
proftpd(),
vsftpd(),
ftpstats(),
xferlog(),
php(),
oops(),
icecast(),
fetchmail(),
apm(),
distcc(),
sulog(),
super_(),
ulogd(),
]
}
fn dpkg() -> Module {
Module::new(
"dpkg",
"Coloriser for dpkg logs.",
vec![
RuleDef::new(
"dpkg-status",
r"^(?P<date>[-\d]{10}\s[:\d]{8})\sstatus\s(?P<pkgstatus>\S+)\s(?P<pkg>\S+)\s\S+$",
),
RuleDef::new(
"dpkg-action",
r"^(?P<date>[-\d]{10}\s[:\d]{8})\s(?:install|upgrade|remove|purge)\s(?P<pkg>\S+)\s\S+\s\S+$",
),
RuleDef::new(
"dpkg-conffile",
r"^(?P<date>[-\d]{10}\s[:\d]{8})\sconffile\s(?P<file>\S+)\s(?:install|keep)$",
),
RuleDef::with_token(
"dpkg-keyword",
r"\b(?:status|conffile|install|upgrade|remove|purge|keep)\b",
"keyword",
),
],
)
}
fn postfix() -> Module {
Module::new(
"postfix",
"Coloriser for postfix(1) sub-logs.",
vec![RuleDef::new(
"postfix-line",
r"^(?P<unique>[\dA-F]+): (?P<field>client|to|message-id|uid|resent-message-id|from)=",
)],
)
}
fn exim() -> Module {
Module::new(
"exim",
"Coloriser for exim logs.",
vec![
RuleDef::new(
"exim-action",
r"^(?P<date>\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\s(?P<unique>\S{16})\s(?:[<=*][=>*])\s",
),
RuleDef::new(
"exim-uniqn",
r"^(?P<date>\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\s(?P<unique>\S{16})\s",
),
RuleDef::new(
"exim-date",
r"^(?P<date>\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\s",
),
RuleDef::with_token("exim-incoming", r"<[=>*]", "incoming"),
RuleDef::with_token("exim-outgoing", r"[=*]>", "outgoing"),
RuleDef::with_token("exim-error", r"[=*][=*]", "error"),
],
)
}
fn procmail() -> Module {
Module::new(
"procmail",
"Coloriser for procmail(1) logs.",
vec![
RuleDef::new(
"procmail-from",
r"^\s*(?P<field>>?From)\s(?P<email>\S+)\s+(?P<date>.*)$",
)
.ci(),
RuleDef::new(
"procmail-subject",
r"^\s*(?P<field>Subject:)\s(?P<subject>\S+)",
)
.ci(),
RuleDef::new(
"procmail-folder",
r"^\s*(?P<field>Folder:)\s(?P<dir>\S+)\s+(?P<size>.*)$",
)
.ci(),
],
)
}
fn proftpd() -> Module {
Module::new(
"proftpd",
"Coloriser for proftpd access and auth logs.",
vec![
RuleDef::new(
"proftpd-access",
r#"^(?P<host>\d+\.\d+\.\d+\.\d+) (?P<ident>\S+) (?P<user>\S+) \[(?P<date>\d{2}/.{3}/\d{4}:\d{2}:\d{2}:\d{2} [\-+]\d{4})\] "(?P<keyword>[A-Z]+) (?P<uri>[^"]+)" (?P<ftp_code>\d{3}) (?P<getsize>-|\d+)"#,
),
RuleDef::new(
"proftpd-auth",
r#"^(?P<host>\S+) ftp server \[(?P<pid>\d+)\] (?P<ip>\d+\.\d+\.\d+\.\d+) \[(?P<date>\d{2}/.{3}/\d{4}:\d{2}:\d{2}:\d{2} [\-+]\d{4})\] "(?P<keyword>[A-Z]+) (?:[^"]+)" (?P<ftp_code>\d{3})"#,
),
],
)
}
fn vsftpd() -> Module {
Module::new(
"vsftpd",
"Coloriser for vsftpd(8) logs.",
vec![RuleDef::new(
"vsftpd-line",
r"^(?P<date>\S+\s+\S+\s+\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2}\s+\d+)\s+\[pid (?P<pid>\d+)\]\s+(?:\[(?P<user>\S+)\])?\s*",
)],
)
}
fn ftpstats() -> Module {
Module::new(
"ftpstats",
"Coloriser for ftpstats (pure-ftpd) logs.",
vec![RuleDef::new(
"ftpstats-line",
r"^(?P<date>\d{9,10})\s(?P<unique>[\da-f]+\.[\da-f]+)\s(?P<user>\S+)\s(?P<host>\S+)\s(?P<ftp_code>U|D)\s(?P<getsize>\d+)\s(?P<gettime>\d+)\s(?P<dir>.*)$",
)],
)
}
fn xferlog() -> Module {
Module::new(
"xferlog",
"Generic xferlog coloriser.",
vec![RuleDef::new(
"xferlog-line",
r"^(?P<date>... ... +\d{1,2} +\d{1,2}:\d{1,2}:\d{1,2} \d+) (?P<gettime>\d+) (?P<host>[^ ]+) (?P<getsize>\d+) (?P<dir>\S+) (?P<bracket>a|b) (?P<ftp_code>C|U|T|_) (?:o|i) (?:a|g|r) (?P<user>[^ ]+) (?P<service>[^ ]+) (?:0|1) (?P<ident>[^ ]+) (?:c|i)",
)],
)
}
fn php() -> Module {
Module::new(
"php",
"Coloriser for PHP logs.",
vec![
RuleDef::new("php-line", r"^(?P<date>\[\d+-\w{3}-\d+ \d+:\d+:\d+\]) PHP "),
RuleDef::with_token("php-keyword", r"\bPHP\b", "keyword"),
RuleDef::with_token(
"php-error",
r"\b(?:fatal error|parse error|error)\b",
"error",
)
.ci(),
RuleDef::with_token("php-warning", r"\bwarning\b", "warning").ci(),
RuleDef::with_token("php-notice", r"\b(?:notice|deprecated)\b", "debug").ci(),
],
)
}
fn oops() -> Module {
Module::new(
"oops",
"Coloriser for oops proxy logs.",
vec![
RuleDef::new(
"oops-line",
r"^(?P<date>(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d+ \d+:\d+:\d+ \d+)\s+\[(?P<process>[\dxa-fA-F]+)\]statistics\(\): (?P<field>\S+)\s*: (?P<number>\d+)",
),
RuleDef::with_token("oops-keyword", r"statistics\(\)", "keyword"),
],
)
}
fn icecast() -> Module {
Module::new(
"icecast",
"Coloriser for Icecast(8) logs.",
vec![
RuleDef::new(
"icecast-usage",
r"^(?P<date>\[\d+/\w{3}/\d+:\d+:\d+:\d+\]) \[(?P<pid>\d+):(?P<keyword>[^\]]*)\] \[\d+/\w{3}/\d+:\d+:\d+:\d+\] Bandwidth:(?P<size>[\d.]+)\S* Sources:(?P<number>\d+) Clients:\d+ Admins:\d+",
),
RuleDef::new(
"icecast-line",
r"^(?P<date>\[\d+/\w{3}/\d+:\d+:\d+:\d+\]) (?:(?P<keyword>Admin) )?\[(?P<pid>\d*):?(?P<host>[^\]]*)\] ",
),
RuleDef::with_token(
"icecast-label",
r"\b(?:Bandwidth|Sources|Clients|Admins):",
"keyword",
),
RuleDef::with_token("icecast-date", r"\[\d+/\w{3}/\d+:\d+:\d+:\d+\]", "date"),
],
)
}
fn fetchmail() -> Module {
Module::new(
"fetchmail",
"Coloriser for fetchmail(1) sub-logs.",
vec![RuleDef::new(
"fetchmail-line",
r"reading message (?P<email>[^@]*@[^:]*):(?P<number>\d+) of (?P<size>\d+)",
)],
)
}
fn apm() -> Module {
Module::new(
"apm",
"Coloriser for APM sub-logs.",
vec![
RuleDef::new("apm-battery", r"Battery: (?P<percentage>-?\d+)%"),
RuleDef::new("apm-charge", r"%, (?P<system>.*charging) \("),
RuleDef::new("apm-rate", r"charging \((?P<percentage>-?\d+)%"),
RuleDef::new("apm-elapsed", r"% \S* (?P<date>\d+:\d+:\d+)\),"),
RuleDef::new("apm-remain", r"\), (?P<date>\d+:\d+:\d+) "),
],
)
}
fn distcc() -> Module {
Module::new(
"distcc",
"Coloriser for distcc(1) logs.",
vec![RuleDef::new(
"distcc-line",
r"^(?P<process>distccd)\[(?P<pid>\d+)\] (?P<keyword>\([^)]+\))?",
)],
)
}
fn sulog() -> Module {
Module::new(
"sulog",
"Coloriser for su(1) logs.",
vec![
RuleDef::new(
"sulog-tty-unknown",
r"^SU \d{2}/\d{2} \d{2}:\d{2} [+-] (?P<unknown>\?\S*) ",
),
RuleDef::new(
"sulog-line",
r"^SU (?P<date>\d{2}/\d{2} \d{2}:\d{2}) [+-] (?P<dir>\S+) (?P<user>[^-]+)-",
),
RuleDef::new(
"sulog-touser",
r"^SU \d{2}/\d{2} \d{2}:\d{2} [+-] \S+ [^-]+-(?P<user>.*)$",
),
],
)
}
fn super_() -> Module {
Module::new(
"super",
"Coloriser for super(1) logs.",
vec![RuleDef::new(
"super-line",
r"^(?P<email>\S+)\s(?P<date>\w+\s+\w+\s+\d+\s+\d+:\d+:\d+\s+\d+)\s+(?P<process>\S+)\s\([^)]+\)",
)],
)
}
fn ulogd() -> Module {
Module::new(
"ulogd",
"Coloriser for ulogd sub-logs.",
vec![RuleDef::new(
r"ulogd-field",
r"\b(?P<field>IN|OUT|PHYSIN|PHYSOUT|MAC|SRC|DST|LEN|TOS|PREC|TTL|ID|PROTO|SPT|DPT|SEQ|ACK|WINDOW|RES|URGP|TYPE|CODE|MARK|GID|UID)=",
)],
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::compile_all;
#[test]
fn all_modules_compile() {
for m in all() {
compile_all(&m.rules)
.unwrap_or_else(|e| panic!("module `{}` has a bad rule: {e}", m.name));
}
}
#[test]
fn resolve_all_keyword() {
let defs = resolve(&["all".to_string()]).unwrap();
assert!(!defs.is_empty());
}
#[test]
fn resolve_unknown_reports() {
let err = resolve(&["nope".to_string()]).unwrap_err();
assert_eq!(err, vec!["nope".to_string()]);
}
#[test]
fn syslog_module_exists() {
assert!(get("syslog").is_some());
}
#[test]
fn modern_modules_are_wired_in() {
assert!(get("nginx").is_some());
assert!(get("json").is_some());
assert!(get("postgres").is_some());
}
#[test]
fn all_module_names_are_unique() {
let mut names: Vec<&str> = all().iter().map(|m| m.name).collect();
names.sort_unstable();
let n = names.len();
names.dedup();
assert_eq!(
n,
names.len(),
"duplicate module name across legacy + modern"
);
}
#[test]
fn detect_finds_syslog() {
let sample = vec![
"Jun 27 14:03:11 host sshd[1234]: ERROR bad login".to_string(),
"Jun 27 14:03:12 host kernel: something happened".to_string(),
];
assert!(detect(&sample).contains(&"syslog"));
}
#[test]
fn detect_finds_httpd_access() {
let sample = vec![
r#"127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326"#
.to_string(),
];
assert!(detect(&sample).contains(&"httpd"));
}
#[test]
fn detect_returns_nothing_for_plain_text() {
let sample = vec!["just some words with no structure".to_string()];
assert!(detect(&sample).is_empty());
}
#[test]
fn detect_finds_postgres_precisely() {
let sample =
vec!["2026-06-27 10:00:00.123 UTC [123] LOG: database system is ready".to_string()];
let got = detect(&sample);
assert!(got.contains(&"postgres"), "postgres detected: {got:?}");
assert!(
!got.contains(&"json"),
"json must not false-positive: {got:?}"
);
}
#[test]
fn detect_skips_mid_line_only_formats() {
let sample = vec![r#"{"level":"info","msg":"started","ts":"2026-06-27"}"#.to_string()];
assert!(!detect(&sample).contains(&"json"));
}
}