1use std::collections::HashSet;
7use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11use super::length::{tweet_weighted_len, MAX_TWEET_CHARS};
12
13pub const MAX_MEDIA_PER_BLOCK: usize = 4;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct ThreadBlock {
19 pub id: String,
21 pub text: String,
23 #[serde(default)]
25 pub media_paths: Vec<String>,
26 pub order: u32,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32pub struct ThreadBlocksPayload {
33 pub version: u8,
35 pub blocks: Vec<ThreadBlock>,
37}
38
39#[derive(Debug, thiserror::Error)]
41pub enum ThreadBlockError {
42 #[error("thread blocks must not be empty")]
43 EmptyBlocks,
44 #[error("thread must contain at least 2 blocks")]
45 SingleBlock,
46 #[error("duplicate block ID: {id}")]
47 DuplicateBlockId { id: String },
48 #[error("block order must be a contiguous sequence starting at 0")]
49 NonContiguousOrder {
50 expected: Vec<u32>,
51 actual: Vec<u32>,
52 },
53 #[error("block {block_id} has empty text")]
54 EmptyBlockText { block_id: String },
55 #[error("block {block_id}: text exceeds {max} characters (length: {length})")]
56 BlockTextTooLong {
57 block_id: String,
58 length: usize,
59 max: usize,
60 },
61 #[error("block {block_id}: too many media attachments ({count}, max {max})")]
62 TooManyMedia {
63 block_id: String,
64 count: usize,
65 max: usize,
66 },
67 #[error("block at index {index} has an empty ID")]
68 InvalidBlockId { index: usize },
69}
70
71impl ThreadBlockError {
72 pub fn api_message(&self) -> String {
74 self.to_string()
75 }
76}
77
78pub fn validate_thread_blocks(blocks: &[ThreadBlock]) -> Result<(), ThreadBlockError> {
90 if blocks.is_empty() {
91 return Err(ThreadBlockError::EmptyBlocks);
92 }
93 if blocks.len() < 2 {
94 return Err(ThreadBlockError::SingleBlock);
95 }
96
97 for (i, block) in blocks.iter().enumerate() {
99 if block.id.trim().is_empty() {
100 return Err(ThreadBlockError::InvalidBlockId { index: i });
101 }
102 }
103
104 let mut seen_ids = HashSet::with_capacity(blocks.len());
106 for block in blocks {
107 if !seen_ids.insert(&block.id) {
108 return Err(ThreadBlockError::DuplicateBlockId {
109 id: block.id.clone(),
110 });
111 }
112 }
113
114 let mut actual_orders: Vec<u32> = blocks.iter().map(|b| b.order).collect();
116 actual_orders.sort_unstable();
117 let expected_orders: Vec<u32> = (0..blocks.len() as u32).collect();
118 if actual_orders != expected_orders {
119 return Err(ThreadBlockError::NonContiguousOrder {
120 expected: expected_orders,
121 actual: actual_orders,
122 });
123 }
124
125 for block in blocks {
127 if block.text.trim().is_empty() {
128 return Err(ThreadBlockError::EmptyBlockText {
129 block_id: block.id.clone(),
130 });
131 }
132 let weighted_len = tweet_weighted_len(&block.text);
133 if weighted_len > MAX_TWEET_CHARS {
134 return Err(ThreadBlockError::BlockTextTooLong {
135 block_id: block.id.clone(),
136 length: weighted_len,
137 max: MAX_TWEET_CHARS,
138 });
139 }
140 if block.media_paths.len() > MAX_MEDIA_PER_BLOCK {
141 return Err(ThreadBlockError::TooManyMedia {
142 block_id: block.id.clone(),
143 count: block.media_paths.len(),
144 max: MAX_MEDIA_PER_BLOCK,
145 });
146 }
147 }
148
149 Ok(())
150}
151
152pub fn serialize_blocks_for_storage(blocks: &[ThreadBlock]) -> String {
154 let payload = ThreadBlocksPayload {
155 version: 1,
156 blocks: blocks.to_vec(),
157 };
158 serde_json::to_string(&payload).expect("ThreadBlocksPayload serialization cannot fail")
159}
160
161pub fn deserialize_blocks_from_content(content: &str) -> Option<Vec<ThreadBlock>> {
166 let parsed: serde_json::Value = serde_json::from_str(content).ok()?;
167 if let Some(obj) = parsed.as_object() {
168 if obj.contains_key("blocks") {
169 let payload: ThreadBlocksPayload = serde_json::from_str(content).ok()?;
170 return Some(payload.blocks);
171 }
172 }
173 None
174}
175
176impl fmt::Display for ThreadBlock {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 write!(f, "ThreadBlock({}, order={})", self.id, self.order)
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn make_block(id: &str, text: &str, order: u32) -> ThreadBlock {
187 ThreadBlock {
188 id: id.to_string(),
189 text: text.to_string(),
190 media_paths: vec![],
191 order,
192 }
193 }
194
195 fn make_block_with_media(id: &str, text: &str, order: u32, media: Vec<&str>) -> ThreadBlock {
196 ThreadBlock {
197 id: id.to_string(),
198 text: text.to_string(),
199 media_paths: media.into_iter().map(String::from).collect(),
200 order,
201 }
202 }
203
204 #[test]
205 fn valid_two_block_thread() {
206 let blocks = vec![
207 make_block("a", "First tweet", 0),
208 make_block("b", "Second tweet", 1),
209 ];
210 assert!(validate_thread_blocks(&blocks).is_ok());
211 }
212
213 #[test]
214 fn empty_blocks_rejected() {
215 let blocks: Vec<ThreadBlock> = vec![];
216 let err = validate_thread_blocks(&blocks).unwrap_err();
217 assert!(matches!(err, ThreadBlockError::EmptyBlocks));
218 }
219
220 #[test]
221 fn single_block_rejected() {
222 let blocks = vec![make_block("a", "Only tweet", 0)];
223 let err = validate_thread_blocks(&blocks).unwrap_err();
224 assert!(matches!(err, ThreadBlockError::SingleBlock));
225 }
226
227 #[test]
228 fn duplicate_ids_rejected() {
229 let blocks = vec![
230 make_block("same", "First", 0),
231 make_block("same", "Second", 1),
232 ];
233 let err = validate_thread_blocks(&blocks).unwrap_err();
234 assert!(matches!(err, ThreadBlockError::DuplicateBlockId { .. }));
235 }
236
237 #[test]
238 fn non_contiguous_order_rejected() {
239 let blocks = vec![make_block("a", "First", 0), make_block("b", "Second", 2)];
240 let err = validate_thread_blocks(&blocks).unwrap_err();
241 assert!(matches!(err, ThreadBlockError::NonContiguousOrder { .. }));
242 }
243
244 #[test]
245 fn order_not_starting_at_zero_rejected() {
246 let blocks = vec![make_block("a", "First", 1), make_block("b", "Second", 2)];
247 let err = validate_thread_blocks(&blocks).unwrap_err();
248 assert!(matches!(err, ThreadBlockError::NonContiguousOrder { .. }));
249 }
250
251 #[test]
252 fn empty_text_rejected() {
253 let blocks = vec![make_block("a", " ", 0), make_block("b", "Second", 1)];
254 let err = validate_thread_blocks(&blocks).unwrap_err();
255 assert!(matches!(err, ThreadBlockError::EmptyBlockText { .. }));
256 }
257
258 #[test]
259 fn text_over_limit_rejected() {
260 let long_text = "a".repeat(281);
261 let blocks = vec![make_block("a", &long_text, 0), make_block("b", "Short", 1)];
262 let err = validate_thread_blocks(&blocks).unwrap_err();
263 assert!(matches!(err, ThreadBlockError::BlockTextTooLong { .. }));
264 }
265
266 #[test]
267 fn too_many_media_rejected() {
268 let blocks = vec![
269 make_block_with_media(
270 "a",
271 "Text",
272 0,
273 vec!["1.jpg", "2.jpg", "3.jpg", "4.jpg", "5.jpg"],
274 ),
275 make_block("b", "Second", 1),
276 ];
277 let err = validate_thread_blocks(&blocks).unwrap_err();
278 assert!(matches!(err, ThreadBlockError::TooManyMedia { .. }));
279 }
280
281 #[test]
282 fn four_media_accepted() {
283 let blocks = vec![
284 make_block_with_media("a", "Text", 0, vec!["1.jpg", "2.jpg", "3.jpg", "4.jpg"]),
285 make_block("b", "Second", 1),
286 ];
287 assert!(validate_thread_blocks(&blocks).is_ok());
288 }
289
290 #[test]
291 fn empty_block_id_rejected() {
292 let blocks = vec![make_block("", "First", 0), make_block("b", "Second", 1)];
293 let err = validate_thread_blocks(&blocks).unwrap_err();
294 assert!(matches!(err, ThreadBlockError::InvalidBlockId { .. }));
295 }
296
297 #[test]
298 fn url_weighted_length_respected() {
299 let padding = "a".repeat(260);
301 let text = format!("{padding} https://example.com");
302 let blocks = vec![make_block("a", &text, 0), make_block("b", "Short", 1)];
303 let err = validate_thread_blocks(&blocks).unwrap_err();
304 assert!(matches!(err, ThreadBlockError::BlockTextTooLong { .. }));
305 }
306
307 #[test]
308 fn url_within_limit_accepted() {
309 let padding = "a".repeat(250);
311 let text = format!("{padding} https://example.com/{}", "x".repeat(76));
312 let blocks = vec![make_block("a", &text, 0), make_block("b", "Short", 1)];
313 assert!(validate_thread_blocks(&blocks).is_ok());
314 }
315
316 #[test]
317 fn serialize_and_deserialize_roundtrip() {
318 let blocks = vec![
319 make_block_with_media("uuid-1", "First tweet", 0, vec!["photo.jpg"]),
320 make_block("uuid-2", "Second tweet", 1),
321 ];
322
323 let serialized = serialize_blocks_for_storage(&blocks);
324 let deserialized = deserialize_blocks_from_content(&serialized);
325
326 assert_eq!(deserialized, Some(blocks));
327 }
328
329 #[test]
330 fn deserialize_legacy_string_array_returns_none() {
331 let legacy = r#"["tweet 1","tweet 2"]"#;
332 assert_eq!(deserialize_blocks_from_content(legacy), None);
333 }
334
335 #[test]
336 fn deserialize_plain_string_returns_none() {
337 assert_eq!(deserialize_blocks_from_content("just a tweet"), None);
338 }
339
340 #[test]
341 fn deserialize_invalid_json_returns_none() {
342 assert_eq!(deserialize_blocks_from_content("{not valid"), None);
343 }
344
345 #[test]
346 fn out_of_order_blocks_accepted_if_contiguous() {
347 let blocks = vec![
349 make_block("a", "Second but order 1", 1),
350 make_block("b", "First but order 0", 0),
351 ];
352 assert!(validate_thread_blocks(&blocks).is_ok());
353 }
354}