use super::buffer::Group;
use crate::limits::{self, EMBED_DESCRIPTION_LIMIT, EMBED_TITLE_LIMIT, MESSAGE_CHARACTER_BUDGET};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chunk {
pub title: String,
pub description: String,
}
fn title(name: &str, suffix: &str) -> String {
let name_budget = EMBED_TITLE_LIMIT.saturating_sub(suffix.chars().count());
format!("{}{suffix}", limits::fit(name, name_budget))
}
#[must_use]
pub fn chunks(group: &Group) -> Vec<Chunk> {
let characters: Vec<char> = group.text.chars().collect();
let pieces: Vec<String> = characters
.chunks(EMBED_DESCRIPTION_LIMIT)
.map(|piece| piece.iter().collect())
.collect();
let total = pieces.len();
pieces
.into_iter()
.enumerate()
.map(|(index, description)| {
let suffix = if total > 1 {
format!(" ({}/{total})", index + 1)
} else {
String::new()
};
Chunk {
title: title(&group.name, &suffix),
description,
}
})
.collect()
}
#[must_use]
pub fn pack_by_budget<T>(
items: Vec<T>,
budget: usize,
max_count: usize,
size: impl Fn(&T) -> usize,
) -> Vec<Vec<T>> {
let mut batches: Vec<Vec<T>> = Vec::new();
let mut current_total = 0usize;
for item in items {
let item_size = size(&item);
let current = batches.last();
let fits_current = current
.is_some_and(|batch| batch.len() < max_count && current_total + item_size <= budget);
if fits_current {
current_total += item_size;
} else {
batches.push(Vec::new());
current_total = item_size;
}
batches.last_mut().expect("just pushed if empty").push(item);
}
batches
}
#[must_use]
pub fn into_messages(chunks: Vec<Chunk>) -> Vec<Vec<Chunk>> {
pack_by_budget(
chunks,
MESSAGE_CHARACTER_BUDGET,
limits::EMBED_MAX_COUNT,
|chunk| chunk.title.chars().count() + chunk.description.chars().count(),
)
}
#[must_use]
pub fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\u{1b}' {
out.push(ch);
continue;
}
if chars.peek() == Some(&'[') {
chars.next();
for parameter_or_final in chars.by_ref() {
if parameter_or_final.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::{
Chunk, EMBED_DESCRIPTION_LIMIT, EMBED_TITLE_LIMIT, MESSAGE_CHARACTER_BUDGET, chunks,
into_messages, strip_ansi,
};
use crate::stream::buffer::Group;
fn group(name: &str, text: &str) -> Group {
Group {
name: name.to_owned(),
at_ms: 0,
text: text.to_owned(),
}
}
fn message_character_count(message: &[Chunk]) -> usize {
message
.iter()
.map(|chunk| chunk.title.chars().count() + chunk.description.chars().count())
.sum()
}
#[test]
fn a_short_group_is_one_untitled_chunk() {
let group_chunks = chunks(&group("web", "hello"));
assert_eq!(group_chunks.len(), 1);
assert_eq!(group_chunks[0].title, "web");
assert_eq!(group_chunks[0].description, "hello");
}
#[test]
fn a_long_group_splits_at_the_description_limit_and_numbers_itself() {
let group_chunks = chunks(&group("web", &"x".repeat(EMBED_DESCRIPTION_LIMIT + 1)));
assert_eq!(group_chunks.len(), 2);
assert_eq!(group_chunks[0].title, "web (1/2)");
assert_eq!(group_chunks[1].title, "web (2/2)");
assert_eq!(
group_chunks[0].description.chars().count(),
EMBED_DESCRIPTION_LIMIT
);
assert_eq!(group_chunks[1].description.chars().count(), 1);
}
#[test]
fn two_full_chunks_never_ride_on_one_message() {
let group_chunks = chunks(&group("web", &"x".repeat(EMBED_DESCRIPTION_LIMIT * 2)));
assert_eq!(group_chunks.len(), 2);
let messages = into_messages(group_chunks);
assert_eq!(messages.len(), 2, "each full chunk needs its own message");
for message in &messages {
let total = message_character_count(message);
assert!(total <= MESSAGE_CHARACTER_BUDGET, "{total} over budget");
}
}
#[test]
fn small_chunks_share_a_message_until_the_budget_is_spent() {
let small_chunks: Vec<Chunk> = (0..10)
.map(|i| Chunk {
title: format!("sheep{i}"),
description: "x".repeat(1_000),
})
.collect();
let messages = into_messages(small_chunks);
assert!(messages.len() >= 2);
for message in &messages {
let total = message_character_count(message);
assert!(total <= MESSAGE_CHARACTER_BUDGET, "{total} over budget");
}
assert_eq!(
messages.iter().map(Vec::len).sum::<usize>(),
10,
"no chunk is lost"
);
}
#[test]
fn the_budget_counts_characters_not_bytes() {
let group_chunks = chunks(&group("web", &"é".repeat(EMBED_DESCRIPTION_LIMIT)));
assert_eq!(
group_chunks.len(),
1,
"4,096 characters is one chunk however many bytes it is"
);
}
#[test]
fn ansi_escapes_do_not_reach_discord() {
assert_eq!(strip_ansi("\u{1b}[31mred\u{1b}[0m"), "red");
}
#[test]
fn a_name_longer_than_the_title_limit_is_truncated_to_it() {
let long_name = "s".repeat(EMBED_TITLE_LIMIT * 2);
let group_chunks = chunks(&group(&long_name, "hello"));
assert_eq!(group_chunks.len(), 1);
assert_eq!(group_chunks[0].title.chars().count(), EMBED_TITLE_LIMIT);
assert!(group_chunks[0].title.ends_with('\u{2026}'));
}
#[test]
fn a_long_name_across_several_chunks_keeps_its_suffix_on_every_title() {
let long_name = "s".repeat(EMBED_TITLE_LIMIT * 2);
let group_chunks = chunks(&group(&long_name, &"x".repeat(EMBED_DESCRIPTION_LIMIT + 1)));
assert_eq!(group_chunks.len(), 2);
assert!(group_chunks[0].title.ends_with(" (1/2)"));
assert!(group_chunks[1].title.ends_with(" (2/2)"));
for chunk in &group_chunks {
assert_eq!(chunk.title.chars().count(), EMBED_TITLE_LIMIT);
}
}
#[test]
fn a_long_name_and_a_long_text_still_produce_messages_under_budget() {
let long_name = "s".repeat(EMBED_TITLE_LIMIT * 2);
let group_chunks = chunks(&group(&long_name, &"x".repeat(EMBED_DESCRIPTION_LIMIT * 3)));
let messages = into_messages(group_chunks);
assert!(!messages.is_empty());
for message in &messages {
let total = message_character_count(message);
assert!(total <= MESSAGE_CHARACTER_BUDGET, "{total} over budget");
for chunk in message {
assert!(
chunk.title.chars().count() <= EMBED_TITLE_LIMIT,
"{} over the title limit",
chunk.title
);
}
}
}
}