1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Button {
15 pub id: String,
18 pub label: String,
20 pub emoji: Option<String>,
22 pub style: ButtonStyle,
24 pub disabled: bool,
26}
27
28#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
30pub enum ButtonStyle {
31 Primary,
33 Secondary,
35 Success,
37 Danger,
39}
40
41impl Default for ButtonStyle {
42 fn default() -> Self {
43 Self::Primary
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SelectOption {
50 pub label: String,
52 pub value: String,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub enum ActionRow {
59 Buttons(Vec<Button>),
61 SelectMenu {
63 placeholder: String,
65 options: Vec<SelectOption>,
67 },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct RichItem {
74 pub title: Option<String>,
76 pub url: Option<String>,
78 pub body: String,
80 pub fields: Vec<(String, String)>,
82 pub footer: Option<String>,
84 pub color: Option<u32>,
86}
87
88impl RichItem {
89 pub fn new(body: impl Into<String>) -> Self {
91 Self {
92 title: None,
93 url: None,
94 body: body.into(),
95 fields: Vec::new(),
96 footer: None,
97 color: None,
98 }
99 }
100
101 pub fn title(mut self, t: impl Into<String>) -> Self {
103 self.title = Some(t.into());
104 self
105 }
106
107 pub fn url(mut self, u: impl Into<String>) -> Self {
109 self.url = Some(u.into());
110 self
111 }
112
113 pub fn field(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
115 self.fields.push((k.into(), v.into()));
116 self
117 }
118
119 pub fn footer(mut self, f: impl Into<String>) -> Self {
121 self.footer = Some(f.into());
122 self
123 }
124
125 pub fn color(mut self, c: u32) -> Self {
127 self.color = Some(c);
128 self
129 }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub enum PlatformMessage {
135 Text(String),
137 Rich {
140 header: Option<String>,
142 items: Vec<RichItem>,
144 actions: Vec<ActionRow>,
146 },
147 File {
149 filename: String,
151 data: Vec<u8>,
153 caption: Option<String>,
155 },
156 Ephemeral(Box<PlatformMessage>),
159}
160
161impl PlatformMessage {
162 pub fn text(s: impl Into<String>) -> Self {
164 Self::Text(s.into())
165 }
166
167 pub fn ephemeral(msg: PlatformMessage) -> Self {
169 Self::Ephemeral(Box::new(msg))
170 }
171
172 pub fn describe(&self) -> String {
174 match self {
175 Self::Text(s) => format!("text: {}", truncate(s, 60)),
176 Self::Rich { header, items, .. } => format!(
177 "rich: {} item(s){}",
178 items.len(),
179 header
180 .as_ref()
181 .map(|h| format!(" header={}", truncate(h, 40)))
182 .unwrap_or_default()
183 ),
184 Self::File { filename, .. } => format!("file: {filename}"),
185 Self::Ephemeral(inner) => format!("ephemeral({})", inner.describe()),
186 }
187 }
188}
189
190pub fn pagination_row(
192 session_id: &str,
193 total: usize,
194 page_size: usize,
195 current_page: usize,
196) -> ActionRow {
197 let total_pages = if total == 0 { 1 } else { (total + page_size - 1) / page_size };
198 let pages = if total_pages == 0 { 1 } else { total_pages };
199 let s = session_id.to_string();
200 ActionRow::Buttons(vec![
201 Button {
202 id: format!("{s}:first"),
203 label: String::new(),
204 emoji: Some("⏮".into()),
205 style: ButtonStyle::Secondary,
206 disabled: current_page <= 1,
207 },
208 Button {
209 id: format!("{s}:prev"),
210 label: String::new(),
211 emoji: Some("⬅".into()),
212 style: ButtonStyle::Secondary,
213 disabled: current_page <= 1,
214 },
215 Button {
216 id: format!("{s}:next"),
217 label: String::new(),
218 emoji: Some("➡".into()),
219 style: ButtonStyle::Secondary,
220 disabled: current_page >= pages,
221 },
222 Button {
223 id: format!("{s}:last"),
224 label: String::new(),
225 emoji: Some("⏭".into()),
226 style: ButtonStyle::Secondary,
227 disabled: current_page >= pages,
228 },
229 ])
230}
231
232fn truncate(s: &str, max: usize) -> String {
233 if s.chars().count() <= max {
234 s.to_string()
235 } else {
236 let cut: String = s.chars().take(max).collect();
237 format!("{cut}…")
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn pagination_row_disables_at_first() {
247 let row = pagination_row("sess", 10, 5, 1);
248 match row {
249 ActionRow::Buttons(bs) => {
250 assert_eq!(bs.len(), 4);
251 assert!(bs[0].disabled && bs[1].disabled);
252 assert!(!bs[2].disabled && !bs[3].disabled);
253 assert_eq!(bs[2].id, "sess:next");
254 }
255 _ => panic!("expected buttons"),
256 }
257 }
258
259 #[test]
260 fn pagination_row_disables_at_last() {
261 let row = pagination_row("sess", 10, 5, 2);
262 match row {
263 ActionRow::Buttons(bs) => {
264 assert!(bs[2].disabled && bs[3].disabled);
265 }
266 _ => panic!("expected buttons"),
267 }
268 }
269
270 #[test]
271 fn rich_item_builds() {
272 let item = RichItem::new("hello")
273 .title("T")
274 .url("https://x")
275 .field("k", "v")
276 .footer("f")
277 .color(0x2ecc71);
278 assert_eq!(item.title.as_deref(), Some("T"));
279 assert_eq!(item.fields.len(), 1);
280 assert_eq!(item.footer.as_deref(), Some("f"));
281 }
282
283 #[test]
284 fn message_describe_truncates() {
285 let m = PlatformMessage::text("x".repeat(100));
286 assert!(m.describe().len() < 90);
287 }
288}