use core::fmt;
use schemars::JsonSchema;
use serde::Deserialize;
use shep_client::{dogs::dog_config, shep_core::values::UpDuration};
use crate::error::Error;
#[dog_config]
#[derive(Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(title = "discord", description = "Settings for the shep-discord dog.")]
pub struct Section {
#[shep(secret)]
#[schemars(description = "Discord bot token.")]
pub token: Option<String>,
#[schemars(description = "The guild (server) this bot serves.")]
pub guild_id: Option<u64>,
#[schemars(description = "Channel for the live monitor. Unset disables it.")]
pub monitor_channel: Option<u64>,
#[schemars(
with = "Option<UpDuration>",
description = "How often the monitor refreshes, e.g. \"1m\". Unset means the monitor does not run from boot."
)]
pub monitor_interval: Option<String>,
#[schemars(description = "Channel for stdout lines. Unset disables that stream.")]
pub log_channel: Option<u64>,
#[schemars(description = "Channel for stderr lines. Unset disables that stream.")]
pub err_channel: Option<u64>,
#[schemars(
with = "Option<UpDuration>",
description = "How often the buffer drains, e.g. \"1s\"."
)]
pub flush: Option<String>,
#[schemars(
with = "Option<UpDuration>",
description = "How wide a window joins lines into one embed, e.g. \"1s\"."
)]
pub coalesce: Option<String>,
#[schemars(description = "How many lines the buffer holds before dropping oldest.")]
pub buffer_lines: Option<usize>,
#[schemars(description = "Hide other dogs from listings and the monitor.")]
pub ignore_dogs: Option<bool>,
}
struct Redacted;
impl fmt::Debug for Redacted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "<redacted>")
}
}
macro_rules! redacted_debug {
($name:ident { token, $($field:ident),+ $(,)? }) => {
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self { token: _, $($field),+ } = self;
f.debug_struct(stringify!($name))
.field("token", &Redacted)
$(.field(stringify!($field), $field))+
.finish()
}
}
};
}
redacted_debug!(Section {
token,
guild_id,
monitor_channel,
monitor_interval,
log_channel,
err_channel,
flush,
coalesce,
buffer_lines,
ignore_dogs,
});
#[derive(PartialEq)]
pub struct Config {
pub token: String,
pub guild_id: u64,
pub monitor_channel: Option<u64>,
pub monitor_interval: Option<UpDuration>,
pub log_channel: Option<u64>,
pub err_channel: Option<u64>,
pub flush: UpDuration,
pub coalesce: UpDuration,
pub buffer_lines: usize,
pub ignore_dogs: bool,
}
redacted_debug!(Config {
token,
guild_id,
monitor_channel,
monitor_interval,
log_channel,
err_channel,
flush,
coalesce,
buffer_lines,
ignore_dogs,
});
pub const DEFAULT_FLUSH_MS: u64 = 1_000;
pub const DEFAULT_COALESCE_MS: u64 = 1_000;
pub const DEFAULT_BUFFER_LINES: usize = 2_000;
pub const MIN_MONITOR_INTERVAL_MS: u64 = 15_000;
fn parse_duration(value: &str, field: &'static str) -> Result<UpDuration, Error> {
value.parse::<UpDuration>().map_err(|source| {
Error::Config(format!("{field} is not a duration shep accepts: {source}"))
})
}
fn parse_failure(text: &str, err: &toml::de::Error) -> Error {
let offset = err.span().map_or(0, |span| span.start).min(text.len());
let line_number = text[..offset].matches('\n').count() + 1;
Error::Config(format!(
"line {line_number} does not parse. This dog will not print what is on it or what the \
parser made of it, because a mistyped section is exactly where a bot token ends up in \
the wrong place. Compare that line against shep-discord --print-config."
))
}
fn refuse_zero(value: Option<u64>, field: &'static str) -> Result<Option<u64>, Error> {
match value {
Some(0) => Err(Error::Config(format!("{field} must not be 0"))),
other => Ok(other),
}
}
impl Config {
pub fn from_toml(text: &str) -> Result<Self, Error> {
let section: Section = toml::from_str(text).map_err(|err| parse_failure(text, &err))?;
let guild_id = refuse_zero(section.guild_id, "guild_id")?;
let monitor_channel = refuse_zero(section.monitor_channel, "monitor_channel")?;
let log_channel = refuse_zero(section.log_channel, "log_channel")?;
let err_channel = refuse_zero(section.err_channel, "err_channel")?;
let flush = section
.flush
.map(|value| parse_duration(&value, "flush"))
.transpose()?
.unwrap_or(UpDuration::from_millis(DEFAULT_FLUSH_MS));
let coalesce = section
.coalesce
.map(|value| parse_duration(&value, "coalesce"))
.transpose()?
.unwrap_or(UpDuration::from_millis(DEFAULT_COALESCE_MS));
let floor = UpDuration::from_millis(MIN_MONITOR_INTERVAL_MS);
let monitor_interval = section
.monitor_interval
.map(|value| parse_duration(&value, "monitor_interval"))
.transpose()?
.map(|interval| interval.max(floor));
let buffer_lines = section.buffer_lines.unwrap_or(DEFAULT_BUFFER_LINES);
if buffer_lines == 0 {
return Err(Error::Config("buffer_lines must be at least 1".to_owned()));
}
let token = section
.token
.ok_or_else(|| Error::Unconfigured("token is required".to_owned()))?;
let guild_id =
guild_id.ok_or_else(|| Error::Unconfigured("guild_id is required".to_owned()))?;
Ok(Self {
token,
guild_id,
monitor_channel,
monitor_interval,
log_channel,
err_channel,
flush,
coalesce,
buffer_lines,
ignore_dogs: section.ignore_dogs.unwrap_or(false),
})
}
}
pub const PRINT_CONFIG: &str = r#"[discord]
# Discord bot token. Required: shep-discord will not run without one.
#token = "your-bot-token"
# The guild (server) this bot serves. Required.
#guild_id = 123456789012345678
# Channel for the live monitor. Unset disables it.
#monitor_channel = 123456789012345678
# How often the monitor refreshes, e.g. "1m". Unset means the monitor does
# not run from boot. Below 15s is raised to it: a monitor refreshing that
# often spends the channel's whole rate budget redrawing embeds nobody
# asked for.
#monitor_interval = "1m"
# Channel for stdout lines. Unset disables that stream.
#log_channel = 123456789012345678
# Channel for stderr lines. Unset disables that stream.
#err_channel = 123456789012345678
# How often the buffer drains, e.g. "1s".
#flush = "1s"
# How wide a window joins lines into one embed, e.g. "1s".
#coalesce = "1s"
# How many lines the buffer holds before dropping the oldest.
#buffer_lines = 2000
# Hide other dogs from listings and the monitor.
#ignore_dogs = false
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_token_never_reaches_a_debug_line() {
let section = Section {
token: Some("MTIzNDU2Nzg5.GaBcDe.ThisIsNotARealToken".to_owned()),
guild_id: Some(42),
..Section::default()
};
let rendered = format!("{section:?}");
assert_eq!(
rendered,
"Section { token: <redacted>, guild_id: Some(42), monitor_channel: None, \
monitor_interval: None, log_channel: None, err_channel: None, flush: None, \
coalesce: None, buffer_lines: None, ignore_dogs: None }"
);
assert!(!rendered.contains("ThisIsNotARealToken"), "{rendered}");
}
#[test]
fn a_resolved_config_never_reaches_a_debug_line_either() {
let config = Config::from_toml(
"token = \"MTIzNDU2Nzg5.GaBcDe.ThisIsNotARealToken\"\nguild_id = 42\n",
)
.expect("parsed");
let rendered = format!("{config:?}");
assert_eq!(
rendered,
"Config { token: <redacted>, guild_id: 42, monitor_channel: None, \
monitor_interval: None, log_channel: None, err_channel: None, \
flush: UpDuration(1s), coalesce: UpDuration(1s), buffer_lines: 2000, \
ignore_dogs: false }"
);
assert!(!rendered.contains("ThisIsNotARealToken"), "{rendered}");
}
#[test]
fn a_section_without_a_token_names_the_missing_key() {
let err = Config::from_toml("guild_id = 1")
.expect_err("refused")
.to_string();
assert!(err.contains("token"), "{err}");
}
#[test]
fn an_empty_section_still_names_what_is_missing() {
let err = Config::from_toml("").expect_err("refused").to_string();
assert!(err.contains("token"), "{err}");
}
#[test]
fn a_section_that_does_not_parse_never_says_what_is_on_the_token_line() {
for (toml, secret) in [
(
"token = \"MTIzNDU2Nzg5.SUPERSECRET.abcdef\" trailing garbage\nguild_id = 1\n",
"SUPERSECRET",
),
("token = 99SECRETNUM88\nguild_id = 1\n", "SECRETNUM"),
("token = 9912345678\nguild_id = 1\n", "9912345678"),
(
"guild_id = 1\ntoken = \"MTIz.ANOTHERSECRET.xyz\" oops\n",
"ANOTHERSECRET",
),
(
"token = \"t\"\nguild_id = \"MTIz.WRONGKEY.abc\"\n",
"WRONGKEY",
),
(
"token = \"t\"\nguild_id = 1\nlog_channel = \"MTIz.CHANNELKEY.abc\"\n",
"CHANNELKEY",
),
(
"token = \"t\"\nguild_id = 1\nmonitor_channel = \"MTIz.MONITORKEY.abc\"\n",
"MONITORKEY",
),
(
"token = \"t\"\nguild_id = 1\nerr_channel = \"MTIz.ERRKEY.abc\"\n",
"ERRKEY",
),
(
"token = \"t\"\nguild_id = 1\nbuffer_lines = \"MTIz.LINESKEY.abc\"\n",
"LINESKEY",
),
(
"token = \"t\"\nguild_id = 1\nignore_dogs = \"MTIz.DOGSKEY.abc\"\n",
"DOGSKEY",
),
(
"token = \"t\"\nguild_id = 1\nflush = \"MTIz.FLUSHKEY.abc\"\n",
"FLUSHKEY",
),
(
"token = \"t\"\nguild_id = 1\ncoalesce = \"MTIz.COALESCEKEY.abc\"\n",
"COALESCEKEY",
),
(
"token = \"t\"\nguild_id = 1\nmonitor_interval = \"MTIz.INTERVALKEY.abc\"\n",
"INTERVALKEY",
),
] {
let err = Config::from_toml(toml).expect_err(toml).to_string();
assert!(!err.contains(secret), "the token reached the error: {err}");
assert!(matches!(
Config::from_toml(toml).expect_err(toml),
Error::Config(_)
));
}
}
#[test]
fn a_parse_failure_still_names_the_line_it_failed_on() {
let err = Config::from_toml("token = \"t\"\nguild_id = 1\nbuffer_lines = = 2\n")
.expect_err("refused")
.to_string();
assert!(err.contains("line 3"), "{err}");
let on_the_token_line =
Config::from_toml("token = \"MTIz.SECRET.abc\" oops\nguild_id = 1\n")
.expect_err("refused")
.to_string();
assert!(on_the_token_line.contains("line 1"), "{on_the_token_line}");
assert!(
on_the_token_line.contains("bot token"),
"it says why it is being terse: {on_the_token_line}"
);
crate::test_support::assert_no_dashes(&on_the_token_line);
}
#[test]
fn a_section_that_is_wrong_is_told_apart_from_one_nobody_filled_in() {
for wrong in [
"this is not TOML at all",
"token = \"t\"\nguild_id = 1\nbuffer_line = 10\n",
"token = \"t\"\nguild_id = 0\n",
"token = \"t\"\nguild_id = 1\nmonitor_channel = 0\n",
"token = \"t\"\nguild_id = 1\nlog_channel = 0\n",
"token = \"t\"\nguild_id = 1\nerr_channel = 0\n",
"token = \"t\"\nguild_id = 1\nflush = \"whenever\"\n",
"token = \"t\"\nguild_id = 1\ncoalesce = \"whenever\"\n",
"token = \"t\"\nguild_id = 1\nmonitor_interval = \"whenever\"\n",
"token = \"t\"\nguild_id = 1\nbuffer_lines = 0\n",
] {
let err = Config::from_toml(wrong).expect_err(wrong);
assert!(matches!(err, Error::Config(_)), "{wrong}: {err:?}");
}
for both in [
"guild_id = 0\n",
"guild_id = 1\nflush = \"garbage\"\n",
"token = \"t\"\nbuffer_lines = 0\n",
"token = \"t\"\nlog_channel = 0\n",
] {
let err = Config::from_toml(both).expect_err(both);
assert!(matches!(err, Error::Config(_)), "{both}: {err:?}");
}
for unfilled in ["", "guild_id = 1\n", "token = \"t\"\n"] {
let err = Config::from_toml(unfilled).expect_err(unfilled);
assert!(matches!(err, Error::Unconfigured(_)), "{unfilled}: {err:?}");
}
}
#[test]
fn a_monitor_interval_under_the_floor_is_raised_to_it() {
let config = Config::from_toml("token = \"t\"\nguild_id = 1\nmonitor_interval = \"1s\"\n")
.expect("parsed");
assert_eq!(
config.monitor_interval.expect("set").as_millis(),
MIN_MONITOR_INTERVAL_MS,
"a monitor refreshing every second spends the channel's whole rate budget"
);
}
#[test]
fn a_zero_buffer_lines_is_refused_rather_than_clamped() {
let err = Config::from_toml("token = \"t\"\nguild_id = 1\nbuffer_lines = 0\n")
.expect_err("refused")
.to_string();
assert!(err.contains("buffer_lines"), "{err}");
}
#[test]
fn a_zero_guild_id_is_refused_rather_than_accepted() {
let err = Config::from_toml("token = \"t\"\nguild_id = 0\n")
.expect_err("refused")
.to_string();
assert!(err.contains("guild_id"), "{err}");
}
#[test]
fn a_zero_channel_is_refused_but_an_unset_one_is_fine() {
for field in ["monitor_channel", "log_channel", "err_channel"] {
let err = Config::from_toml(&format!("token = \"t\"\nguild_id = 1\n{field} = 0\n"))
.expect_err("refused")
.to_string();
assert!(err.contains(field), "{err}");
}
let config =
Config::from_toml("token = \"t\"\nguild_id = 1\n").expect("no channel is legitimate");
assert_eq!(config.monitor_channel, None);
assert_eq!(config.log_channel, None);
assert_eq!(config.err_channel, None);
}
#[test]
fn a_misspelled_key_is_refused_rather_than_ignored() {
let err = Config::from_toml("token = \"t\"\nguild_id = 1\nbuffer_line = 10\n")
.expect_err("refused")
.to_string();
assert!(err.contains("line 3"), "{err}");
assert!(!err.contains("buffer_line"), "{err}");
}
#[test]
fn every_value_the_printed_block_documents_is_the_value_the_code_uses() {
let uncommented: Vec<&str> = PRINT_CONFIG
.lines()
.filter_map(|line| line.strip_prefix('#'))
.filter(|rest| !rest.starts_with(' '))
.collect();
assert_eq!(
uncommented.len(),
10,
"expected one line per setting, got {uncommented:?}"
);
let config =
Config::from_toml(&uncommented.join("\n")).expect("the printed block is valid");
assert_eq!(
config,
Config {
token: "your-bot-token".to_owned(),
guild_id: 123_456_789_012_345_678,
monitor_channel: Some(123_456_789_012_345_678),
monitor_interval: Some("1m".parse().expect("a spelling shep accepts")),
log_channel: Some(123_456_789_012_345_678),
err_channel: Some(123_456_789_012_345_678),
flush: UpDuration::from_millis(DEFAULT_FLUSH_MS),
coalesce: UpDuration::from_millis(DEFAULT_COALESCE_MS),
buffer_lines: DEFAULT_BUFFER_LINES,
ignore_dogs: false,
}
);
}
fn schema_keys() -> Vec<String> {
let schema = shep_client::dogs::config_schema::<Section>();
let mut keys: Vec<String> = schema
.as_value()
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("an object schema has properties")
.keys()
.cloned()
.collect();
keys.sort();
keys
}
#[test]
fn the_token_carries_the_secret_mark_and_nothing_else_does() {
let schema = shep_client::dogs::config_schema::<Section>();
let value = schema.as_value();
assert_eq!(
value.pointer("/properties/token/x-shep-secret"),
Some(&serde_json::Value::Bool(true)),
"{value}"
);
for key in schema_keys().iter().filter(|key| key.as_str() != "token") {
assert_eq!(
value.pointer(&format!("/properties/{key}/x-shep-secret")),
None,
"{key} is not a credential"
);
}
}
#[test]
fn the_schema_and_the_printed_block_name_the_same_settings() {
let mut printed: Vec<String> = PRINT_CONFIG
.lines()
.filter_map(|line| line.strip_prefix('#'))
.filter(|rest| !rest.starts_with(' '))
.filter_map(|setting| setting.split_once(' '))
.map(|(key, _)| key.to_owned())
.collect();
printed.sort();
assert_eq!(printed.len(), 10, "one key per setting, got {printed:?}");
assert_eq!(schema_keys(), printed);
}
#[test]
fn nothing_printed_for_a_person_carries_a_dash() {
crate::test_support::assert_no_dashes(PRINT_CONFIG);
}
}