use serenity::builder::CreatePollAnswer;
pub const QUESTION_MAX: usize = 300;
pub const ANSWER_MAX: usize = 55;
pub const MAX_ANSWERS: usize = 10;
pub const MIN_ANSWERS: usize = 2;
pub const DEFAULT_DURATION_HOURS: u16 = 24;
pub const MAX_DURATION_HOURS: u16 = 768;
#[derive(Debug, PartialEq, Eq)]
pub enum PollError {
EmptyQuestion,
TooFewAnswers(usize),
}
impl PollError {
pub fn message(&self) -> String {
match self {
Self::EmptyQuestion => "send_poll requires a non-empty 'poll_question'.".to_string(),
Self::TooFewAnswers(n) => format!(
"send_poll needs at least {MIN_ANSWERS} non-empty options in 'poll_options', got {n}."
),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct PollSpec {
pub question: String,
pub answers: Vec<String>,
pub duration_hours: u16,
pub allow_multiselect: bool,
pub dropped_answers: usize,
pub duration_clamped: bool,
}
pub fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
s.chars().take(max).collect()
}
pub fn build_spec(
question: &str,
options: &[String],
duration_hours: Option<i64>,
allow_multiselect: bool,
) -> Result<PollSpec, PollError> {
let question = truncate_chars(question.trim(), QUESTION_MAX);
if question.is_empty() {
return Err(PollError::EmptyQuestion);
}
let usable: Vec<String> = options
.iter()
.map(|o| o.trim())
.filter(|o| !o.is_empty())
.map(|o| truncate_chars(o, ANSWER_MAX))
.collect();
if usable.len() < MIN_ANSWERS {
return Err(PollError::TooFewAnswers(usable.len()));
}
let kept = usable.len().min(MAX_ANSWERS);
let dropped_answers = usable.len() - kept;
let answers = usable.into_iter().take(kept).collect();
let (duration_hours, duration_clamped) = match duration_hours {
None => (DEFAULT_DURATION_HOURS, false),
Some(h) if (1..=MAX_DURATION_HOURS as i64).contains(&h) => (h as u16, false),
Some(h) => (if h < 1 { 1 } else { MAX_DURATION_HOURS }, true),
};
Ok(PollSpec {
question,
answers,
duration_hours,
allow_multiselect,
dropped_answers,
duration_clamped,
})
}
pub fn answer_builders(spec: &PollSpec) -> Vec<CreatePollAnswer> {
spec.answers
.iter()
.map(|a| CreatePollAnswer::new().text(a.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serenity::builder::CreatePoll;
fn opts(items: &[&str]) -> Vec<String> {
items.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn empty_question_is_refused() {
assert_eq!(
build_spec(" ", &opts(&["a", "b"]), None, false),
Err(PollError::EmptyQuestion)
);
}
#[test]
fn fewer_than_two_usable_answers_is_refused() {
assert_eq!(
build_spec("Q", &opts(&["only one"]), None, false),
Err(PollError::TooFewAnswers(1))
);
assert_eq!(
build_spec("Q", &opts(&["real", "", " "]), None, false),
Err(PollError::TooFewAnswers(1))
);
}
#[test]
fn blank_options_are_dropped_not_sent() {
let spec = build_spec("Q", &opts(&["a", "", " ", "b"]), None, false).unwrap();
assert_eq!(spec.answers, vec!["a".to_string(), "b".to_string()]);
assert_eq!(spec.dropped_answers, 0);
}
#[test]
fn more_than_ten_answers_is_capped_and_reported() {
let twelve: Vec<String> = (1..=12).map(|i| format!("opt{i}")).collect();
let spec = build_spec("Q", &twelve, None, false).unwrap();
assert_eq!(spec.answers.len(), MAX_ANSWERS);
assert_eq!(spec.dropped_answers, 2);
assert_eq!(spec.answers.first().unwrap(), "opt1");
assert_eq!(spec.answers.last().unwrap(), "opt10");
}
#[test]
fn exactly_ten_answers_is_not_capped() {
let ten: Vec<String> = (1..=10).map(|i| format!("o{i}")).collect();
let spec = build_spec("Q", &ten, None, false).unwrap();
assert_eq!(spec.answers.len(), 10);
assert_eq!(spec.dropped_answers, 0);
}
#[test]
fn long_answer_truncates_to_55_chars() {
let long = "x".repeat(90);
let spec = build_spec("Q", &opts(&["short", &long]), None, false).unwrap();
assert_eq!(spec.answers[1].chars().count(), ANSWER_MAX);
}
#[test]
fn long_question_truncates_to_300_chars() {
let long = "y".repeat(400);
let spec = build_spec(&long, &opts(&["a", "b"]), None, false).unwrap();
assert_eq!(spec.question.chars().count(), QUESTION_MAX);
}
#[test]
fn truncation_is_char_safe_on_multibyte_input() {
let q = "🦀".repeat(350);
let spec = build_spec(&q, &opts(&["a", "b"]), None, false).unwrap();
assert_eq!(spec.question.chars().count(), QUESTION_MAX);
assert!(spec.question.chars().all(|c| c == '🦀'));
assert!(std::str::from_utf8(spec.question.as_bytes()).is_ok());
}
#[test]
fn question_is_trimmed() {
let spec = build_spec(" real question ", &opts(&["a", "b"]), None, false).unwrap();
assert_eq!(spec.question, "real question");
}
#[test]
fn absent_duration_takes_discords_default() {
let spec = build_spec("Q", &opts(&["a", "b"]), None, false).unwrap();
assert_eq!(spec.duration_hours, DEFAULT_DURATION_HOURS);
assert!(!spec.duration_clamped);
}
#[test]
fn in_range_duration_passes_through() {
let spec = build_spec("Q", &opts(&["a", "b"]), Some(48), false).unwrap();
assert_eq!(spec.duration_hours, 48);
assert!(!spec.duration_clamped);
let spec = build_spec("Q", &opts(&["a", "b"]), Some(768), false).unwrap();
assert_eq!(spec.duration_hours, 768);
assert!(!spec.duration_clamped);
}
#[test]
fn zero_or_negative_duration_clamps_up_to_one_hour() {
for h in [0, -1, -1000] {
let spec = build_spec("Q", &opts(&["a", "b"]), Some(h), false).unwrap();
assert_eq!(spec.duration_hours, 1, "duration {h}");
assert!(spec.duration_clamped);
}
}
#[test]
fn oversized_duration_clamps_to_32_days() {
for h in [769, 1000, i64::MAX] {
let spec = build_spec("Q", &opts(&["a", "b"]), Some(h), false).unwrap();
assert_eq!(spec.duration_hours, MAX_DURATION_HOURS, "duration {h}");
assert!(spec.duration_clamped);
}
}
#[test]
fn multiselect_passes_through() {
let spec = build_spec("Q", &opts(&["a", "b"]), None, true).unwrap();
assert!(spec.allow_multiselect);
let spec = build_spec("Q", &opts(&["a", "b"]), None, false).unwrap();
assert!(!spec.allow_multiselect);
}
#[test]
fn answer_builders_mirror_the_spec() {
let spec = build_spec("Q", &opts(&["a", "b", "c"]), None, false).unwrap();
let builders = answer_builders(&spec);
assert_eq!(builders.len(), 3);
let json = serde_json::to_value(&builders).unwrap();
assert_eq!(json[0]["poll_media"]["text"], serde_json::json!("a"));
assert_eq!(json[2]["poll_media"]["text"], serde_json::json!("c"));
}
#[test]
fn spec_serialises_to_the_poll_create_request_shape() {
let spec = build_spec("Cats or dogs?", &opts(&["Cats", "Dogs"]), Some(12), true).unwrap();
let builders = answer_builders(&spec);
let mut poll = CreatePoll::new()
.question(spec.question.clone())
.answers(builders)
.duration(std::time::Duration::from_secs(
u64::from(spec.duration_hours) * 3600,
));
if spec.allow_multiselect {
poll = poll.allow_multiselect();
}
let json = serde_json::to_value(&poll).unwrap();
assert_eq!(json["question"]["text"], serde_json::json!("Cats or dogs?"));
assert_eq!(json["answers"].as_array().unwrap().len(), 2);
assert_eq!(json["duration"], serde_json::json!(12));
assert_eq!(json["allow_multiselect"], serde_json::json!(true));
}
#[test]
fn error_messages_name_the_param_and_the_count() {
assert_eq!(
PollError::EmptyQuestion.message(),
"send_poll requires a non-empty 'poll_question'."
);
assert_eq!(
PollError::TooFewAnswers(1).message(),
"send_poll needs at least 2 non-empty options in 'poll_options', got 1."
);
}
}