use core::fmt;
use schemars::JsonSchema;
use serde::Deserialize;
use shep_client::{dogs::DogConfig, shep_core::values::UpDuration};
use crate::error::Error;
#[derive(Default, Deserialize, JsonSchema, DogConfig)]
#[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>")
}
}
impl fmt::Debug for Section {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Section")
.field("token", &Redacted)
.field("guild_id", &self.guild_id)
.field("monitor_channel", &self.monitor_channel)
.field("monitor_interval", &self.monitor_interval)
.field("log_channel", &self.log_channel)
.field("err_channel", &self.err_channel)
.field("flush", &self.flush)
.field("coalesce", &self.coalesce)
.field("buffer_lines", &self.buffer_lines)
.field("ignore_dogs", &self.ignore_dogs)
.finish()
}
}
#[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,
}
impl fmt::Debug for Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Config")
.field("token", &Redacted)
.field("guild_id", &self.guild_id)
.field("monitor_channel", &self.monitor_channel)
.field("monitor_interval", &self.monitor_interval)
.field("log_channel", &self.log_channel)
.field("err_channel", &self.err_channel)
.field("flush", &self.flush)
.field("coalesce", &self.coalesce)
.field("buffer_lines", &self.buffer_lines)
.field("ignore_dogs", &self.ignore_dogs)
.finish()
}
}
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: String, field: &'static str) -> Result<UpDuration, Error> {
value.parse::<UpDuration>().map_err(|source| {
Error::Config(format!(
"{field} = \"{value}\" is not a duration shep accepts: {source}"
))
})
}
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| Error::Config(err.to_string()))?;
let token = section
.token
.ok_or_else(|| Error::Config("token is required".to_owned()))?;
let guild_id = refuse_zero(section.guild_id, "guild_id")?
.ok_or_else(|| Error::Config("guild_id is required".to_owned()))?;
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()));
}
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_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_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("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>().expect("publishable");
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_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);
}
}