Skip to main content

tuitbot_core/content/
thread.rs

1//! Thread block types and validation for structured thread composition.
2//!
3//! Provides the `ThreadBlock` struct for representing individual tweets
4//! within a thread, along with validation and storage serialization.
5
6use std::collections::HashSet;
7use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11use super::length::{tweet_weighted_len, MAX_TWEET_CHARS};
12
13/// Maximum number of media attachments per block.
14pub const MAX_MEDIA_PER_BLOCK: usize = 4;
15
16/// A single tweet block within a thread.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct ThreadBlock {
19    /// Client-generated stable UUID for this block.
20    pub id: String,
21    /// The tweet text content.
22    pub text: String,
23    /// Local media file paths attached to this block.
24    #[serde(default)]
25    pub media_paths: Vec<String>,
26    /// Zero-based ordering index within the thread.
27    pub order: u32,
28}
29
30/// Versioned wrapper for serializing thread blocks to the database.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32pub struct ThreadBlocksPayload {
33    /// Schema version (currently 1).
34    pub version: u8,
35    /// Ordered list of thread blocks.
36    pub blocks: Vec<ThreadBlock>,
37}
38
39/// Validation errors for thread block payloads.
40#[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    /// Return the user-facing error message for API responses.
73    pub fn api_message(&self) -> String {
74        self.to_string()
75    }
76}
77
78/// Validate a slice of thread blocks.
79///
80/// Checks:
81/// 1. Non-empty blocks array
82/// 2. At least 2 blocks for a thread
83/// 3. All block IDs are non-empty
84/// 4. All block IDs are unique
85/// 5. Order fields form contiguous 0..N sequence
86/// 6. Each block's text is non-empty after trim
87/// 7. Each block's text is within MAX_TWEET_CHARS
88/// 8. Each block has at most MAX_MEDIA_PER_BLOCK media entries
89pub 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    // Validate block IDs are non-empty.
98    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    // Check for duplicate IDs.
105    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    // Validate contiguous order starting at 0.
115    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    // Per-block validation.
126    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
152/// Serialize thread blocks to the versioned JSON format for database storage.
153pub 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
161/// Attempt to deserialize thread blocks from stored content.
162///
163/// Returns `Some(blocks)` if content is a versioned blocks payload.
164/// Returns `None` if content is a legacy format (plain string or string array).
165pub 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        // 260 chars of text + a URL = 260 + 23 = 283, over 280
300        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        // 250 chars + URL = 250 + 23 = 273, under 280
310        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        // Blocks supplied with order [1, 0] — orders are contiguous {0,1}
348        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}