Add multi-network support
Connect to several IRC networks at once. The config file gains optional `[network]` section headers: keys before the first header are shared defaults, and each section defines one network that inherits and overrides them. A file with no sections is a single network exactly as before, so existing configs are unaffected. main spawns one supervisor thread per network, each with its own connect/reconnect/backoff loop, so a failure on one network never disturbs the others. The blocking Bot/Connection are reused unchanged — no async runtime, no new dependencies. With more than one network, wire logs are tagged with the section name; a lone network stays unprefixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ef80b77760
commit
e7e805cb87
4 changed files with 318 additions and 74 deletions
35
README.md
35
README.md
|
|
@ -9,7 +9,7 @@ protocol layer built to the [modern IRC client spec](https://modern.ircdocs.hors
|
|||
|---------------|------------------|-----------------------------------------------------------|
|
||||
| `src/irc.rs` | protocol + I/O | Parse/serialize IRC messages (incl. IRCv3 tags), TCP transport |
|
||||
| `src/bot.rs` | behavior | Registration, event loop, command handling |
|
||||
| `src/main.rs` | wiring | Config from env vars, connect/reconnect supervisor |
|
||||
| `src/main.rs` | wiring | Layered config, one connect/reconnect supervisor thread per network |
|
||||
|
||||
The layers are deliberately separable: `irc.rs` knows nothing about the bot, and
|
||||
`bot.rs` knows nothing about how the process is launched.
|
||||
|
|
@ -40,8 +40,37 @@ RUSTBOT_CONFIG=/etc/rustbot.conf cargo run
|
|||
```
|
||||
|
||||
Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`,
|
||||
`channels` (comma/space-separated), `prefix`, `password`, `sasl_user`, `sasl_pass`.
|
||||
Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s.
|
||||
`channels` (comma/space-separated), `prefix`, `password`, `sasl_user`,
|
||||
`sasl_pass`, and `name` (a network's log label). Whole-line `#`/`;` comments
|
||||
only — inline comments would clash with channel `#`s.
|
||||
|
||||
### Multiple networks
|
||||
|
||||
Group keys under `[name]` section headers to connect to several networks at
|
||||
once — one supervisor thread each, with independent reconnect/backoff. Keys
|
||||
*before* the first header are shared defaults every network inherits; each
|
||||
section overrides them. A file with no headers is a single network (the
|
||||
historical behaviour), and its logs stay unprefixed.
|
||||
|
||||
```ini
|
||||
# shared defaults, inherited by every network below
|
||||
nick = rubot
|
||||
realname = rubot
|
||||
|
||||
[tchatou]
|
||||
server = irc.tchatou.fr
|
||||
tls = true
|
||||
channels = #devs
|
||||
|
||||
[libera]
|
||||
server = irc.libera.chat
|
||||
tls = true
|
||||
channels = #rust, #rust-beginners
|
||||
nick = rubot_ # override just for this network
|
||||
```
|
||||
|
||||
With more than one network, each network's log lines are tagged with its
|
||||
section name (`[libera] << …`) so the interleaved output stays readable.
|
||||
|
||||
Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`,
|
||||
`IRC_REALNAME`, `IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ const HISTORY_GRACE_SECS: u64 = 60;
|
|||
/// Everything the bot needs to know to connect 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.
|
||||
|
|
@ -56,6 +60,7 @@ pub struct Config {
|
|||
impl Default for Config {
|
||||
fn default() -> Config {
|
||||
Config {
|
||||
name: String::new(),
|
||||
server: "irc.tchatou.fr".to_string(),
|
||||
port: 6667,
|
||||
tls: false,
|
||||
|
|
|
|||
20
src/irc.rs
20
src/irc.rs
|
|
@ -197,6 +197,9 @@ impl<T: Read + Write> Stream for T {}
|
|||
/// session — which cannot be cloned into independent halves — work unchanged.
|
||||
pub struct Connection {
|
||||
inner: BufReader<Box<dyn Stream>>,
|
||||
/// Prefix prepended to wire logs (`[name] `) so simultaneous networks stay
|
||||
/// distinguishable. Empty for a single network, keeping its output terse.
|
||||
log_prefix: String,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
|
|
@ -216,7 +219,18 @@ impl Connection {
|
|||
} else {
|
||||
Box::new(tcp)
|
||||
};
|
||||
Ok(Connection { inner: BufReader::new(stream) })
|
||||
Ok(Connection { inner: BufReader::new(stream), log_prefix: String::new() })
|
||||
}
|
||||
|
||||
/// Set a short label prepended to this connection's wire logs, so output
|
||||
/// from multiple simultaneous networks stays distinguishable. An empty
|
||||
/// label clears it (the terse single-network default).
|
||||
pub fn set_label(&mut self, label: &str) {
|
||||
self.log_prefix = if label.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("[{label}] ")
|
||||
};
|
||||
}
|
||||
|
||||
/// Read and parse the next message. Returns `Ok(None)` on a clean EOF
|
||||
|
|
@ -231,7 +245,7 @@ impl Connection {
|
|||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
eprintln!("<< {trimmed}");
|
||||
eprintln!("{}<< {trimmed}", self.log_prefix);
|
||||
if let Some(msg) = Message::parse(trimmed) {
|
||||
return Ok(Some(msg));
|
||||
}
|
||||
|
|
@ -243,7 +257,7 @@ impl Connection {
|
|||
/// any embedded CR/LF are neutralised to prevent command injection.
|
||||
pub fn send_raw(&mut self, line: &str) -> io::Result<()> {
|
||||
let clean = line.replace(['\r', '\n'], " ");
|
||||
eprintln!(">> {clean}");
|
||||
eprintln!("{}>> {clean}", self.log_prefix);
|
||||
let writer = self.inner.get_mut();
|
||||
write!(writer, "{clean}\r\n")?;
|
||||
writer.flush()
|
||||
|
|
|
|||
332
src/main.rs
332
src/main.rs
|
|
@ -7,68 +7,233 @@
|
|||
//!
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
mod bot;
|
||||
mod irc;
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use bot::{Bot, Config};
|
||||
use irc::Connection;
|
||||
|
||||
fn main() {
|
||||
let config = load_config();
|
||||
let configs = load_config();
|
||||
if configs.is_empty() {
|
||||
log("no networks configured; nothing to do");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect();
|
||||
log(&format!(
|
||||
"starting rustbot as {} -> {}:{}",
|
||||
config.nick, config.server, config.port
|
||||
"starting rustbot across {} network(s): {}",
|
||||
configs.len(),
|
||||
names.join(", ")
|
||||
));
|
||||
|
||||
// One supervisor thread per network. Each keeps its own connection alive
|
||||
// with independent reconnect/backoff, so a failure on one network never
|
||||
// disturbs the others. Labels appear in the logs only when there is more
|
||||
// than one network, keeping single-network output terse and unchanged.
|
||||
let labelled = configs.len() > 1;
|
||||
let mut handles = Vec::new();
|
||||
for config in configs {
|
||||
let name = config.name.clone();
|
||||
let handle = thread::Builder::new()
|
||||
.name(name.clone())
|
||||
.spawn(move || supervise(config, labelled))
|
||||
.unwrap_or_else(|e| panic!("failed to spawn thread for {name}: {e}"));
|
||||
handles.push(handle);
|
||||
}
|
||||
for handle in handles {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep one network's bot alive across disconnects, with capped exponential
|
||||
/// backoff. Runs until the process is killed. When `labelled`, log lines are
|
||||
/// tagged with the network name so multiple networks stay distinguishable.
|
||||
fn supervise(config: Config, labelled: bool) {
|
||||
let tag = if labelled {
|
||||
format!("[{}] ", config.name)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
log(&format!(
|
||||
"{tag}connecting as {} -> {}:{} (tls={})",
|
||||
config.nick, config.server, config.port, config.tls
|
||||
));
|
||||
|
||||
// Supervisor: keep the bot alive across disconnects with capped backoff.
|
||||
let mut backoff = 1u64;
|
||||
loop {
|
||||
match run_once(&config) {
|
||||
match run_once(&config, labelled) {
|
||||
Ok(()) => {
|
||||
log("server closed the connection; reconnecting shortly");
|
||||
log(&format!("{tag}server closed the connection; reconnecting shortly"));
|
||||
backoff = 1;
|
||||
}
|
||||
Err(e) => log(&format!("session error: {e}; retrying in {backoff}s")),
|
||||
Err(e) => log(&format!("{tag}session error: {e}; retrying in {backoff}s")),
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(backoff));
|
||||
thread::sleep(Duration::from_secs(backoff));
|
||||
backoff = (backoff * 2).min(60);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a single connection lifecycle: connect, register, loop until disconnect.
|
||||
fn run_once(config: &Config) -> std::io::Result<()> {
|
||||
let conn = Connection::connect(&config.server, config.port, config.tls)?;
|
||||
/// Run a single connection lifecycle for one network: connect, register, loop
|
||||
/// until disconnect.
|
||||
fn run_once(config: &Config, labelled: bool) -> std::io::Result<()> {
|
||||
let mut conn = Connection::connect(&config.server, config.port, config.tls)?;
|
||||
if labelled {
|
||||
conn.set_label(&config.name);
|
||||
}
|
||||
let mut bot = Bot::new(config.clone(), conn);
|
||||
bot.run()
|
||||
}
|
||||
|
||||
/// Build the [`Config`] by layering sources in increasing priority:
|
||||
/// built-in defaults, then a config file, then environment variables.
|
||||
fn load_config() -> Config {
|
||||
let mut c = Config::default();
|
||||
/// 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
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(path) = config_path() {
|
||||
match apply_config_file(&mut c, &path) {
|
||||
Ok(()) => log(&format!("loaded config from {}", path.display())),
|
||||
Err(e) => log(&format!("could not read config {}: {e}", path.display())),
|
||||
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);
|
||||
}
|
||||
}
|
||||
apply_env(&mut c);
|
||||
configs
|
||||
}
|
||||
|
||||
// Convention: TLS listens on 6697. If TLS is enabled but the port is still
|
||||
// the plaintext default, switch to the conventional TLS port.
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
c
|
||||
/// 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
|
||||
|
|
@ -84,49 +249,12 @@ fn config_path() -> Option<PathBuf> {
|
|||
default.exists().then_some(default)
|
||||
}
|
||||
|
||||
/// Parse a `key = value` config file onto `c`.
|
||||
/// Overlay environment variables onto `c` (applied to the shared defaults, so
|
||||
/// they affect every network unless a `[section]` overrides them).
|
||||
///
|
||||
/// Blank lines are ignored. A line whose first non-space character is `#` or
|
||||
/// `;` is a comment — note that inline comments are *not* supported, because
|
||||
/// IRC channel names legitimately contain `#`.
|
||||
fn apply_config_file(c: &mut Config, path: &Path) -> io::Result<()> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
for (i, raw) in content.lines().enumerate() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
log(&format!("{}:{}: ignoring line without '='", path.display(), i + 1));
|
||||
continue;
|
||||
};
|
||||
let (key, value) = (key.trim().to_ascii_lowercase(), value.trim());
|
||||
match key.as_str() {
|
||||
"server" => c.server = value.to_string(),
|
||||
"port" => match value.parse() {
|
||||
Ok(p) => c.port = p,
|
||||
Err(_) => log(&format!("{}:{}: invalid port '{value}'", path.display(), i + 1)),
|
||||
},
|
||||
"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!("{}:{}: unknown key '{other}'", path.display(), i + 1)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Overlay environment variables onto `c` (highest priority — handy for
|
||||
/// one-off overrides without editing the file).
|
||||
///
|
||||
/// Vars: `IRC_SERVER`, `IRC_PORT`, `IRC_NICK`, `IRC_REALNAME`,
|
||||
/// `IRC_CHANNELS` (comma/space-separated), `IRC_PREFIX`, `IRC_PASSWORD`.
|
||||
/// 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;
|
||||
|
|
@ -182,3 +310,71 @@ fn split_list(value: &str) -> Vec<String> {
|
|||
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