Skip to main content

tuitbot_core/toolkit/
write.rs

1//! Stateless write operations over `&dyn XApiClient`.
2//!
3//! Raw posting, replying, quoting, deleting, and thread operations.
4//! No policy enforcement, no audit logging, no mutation recording.
5//! Those concerns belong in the workflow layer (AD-04).
6
7use super::ToolkitError;
8use crate::x_api::types::PostedTweet;
9use crate::x_api::XApiClient;
10
11/// Post a new tweet, optionally with media.
12pub async fn post_tweet(
13    client: &dyn XApiClient,
14    text: &str,
15    media_ids: Option<&[String]>,
16) -> Result<PostedTweet, ToolkitError> {
17    super::validate_tweet_length(text)?;
18    match media_ids {
19        Some(ids) if !ids.is_empty() => Ok(client.post_tweet_with_media(text, ids).await?),
20        _ => Ok(client.post_tweet(text).await?),
21    }
22}
23
24/// Reply to an existing tweet, optionally with media.
25pub async fn reply_to_tweet(
26    client: &dyn XApiClient,
27    text: &str,
28    in_reply_to_id: &str,
29    media_ids: Option<&[String]>,
30) -> Result<PostedTweet, ToolkitError> {
31    super::validate_tweet_length(text)?;
32    super::validate_id(in_reply_to_id, "in_reply_to_id")?;
33    match media_ids {
34        Some(ids) if !ids.is_empty() => Ok(client
35            .reply_to_tweet_with_media(text, in_reply_to_id, ids)
36            .await?),
37        _ => Ok(client.reply_to_tweet(text, in_reply_to_id).await?),
38    }
39}
40
41/// Post a quote tweet referencing another tweet.
42pub async fn quote_tweet(
43    client: &dyn XApiClient,
44    text: &str,
45    quoted_tweet_id: &str,
46) -> Result<PostedTweet, ToolkitError> {
47    super::validate_tweet_length(text)?;
48    super::validate_id(quoted_tweet_id, "quoted_tweet_id")?;
49    Ok(client.quote_tweet(text, quoted_tweet_id).await?)
50}
51
52/// Delete a tweet by ID.
53pub async fn delete_tweet(client: &dyn XApiClient, tweet_id: &str) -> Result<bool, ToolkitError> {
54    super::validate_id(tweet_id, "tweet_id")?;
55    Ok(client.delete_tweet(tweet_id).await?)
56}
57
58/// Post a thread (ordered sequence of tweets).
59///
60/// Validates all tweet lengths up front. Chains replies sequentially.
61/// On partial failure, returns `ToolkitError::ThreadPartialFailure`
62/// with the IDs of successfully posted tweets.
63pub async fn post_thread(
64    client: &dyn XApiClient,
65    tweets: &[String],
66    media_ids: Option<&[Vec<String>]>,
67) -> Result<Vec<String>, ToolkitError> {
68    if tweets.is_empty() {
69        return Err(ToolkitError::InvalidInput {
70            message: "thread must contain at least one tweet".into(),
71        });
72    }
73
74    for (i, text) in tweets.iter().enumerate() {
75        super::validate_tweet_length(text).map_err(|_| ToolkitError::InvalidInput {
76            message: format!(
77                "tweet {i} too long: {} characters (max {})",
78                text.len(),
79                super::MAX_TWEET_LENGTH
80            ),
81        })?;
82    }
83
84    let total = tweets.len();
85    let mut posted_ids: Vec<String> = Vec::with_capacity(total);
86
87    for (i, text) in tweets.iter().enumerate() {
88        let tweet_media = media_ids.and_then(|m| m.get(i)).map(|v| v.as_slice());
89
90        let result = if i == 0 {
91            match tweet_media {
92                Some(ids) if !ids.is_empty() => client.post_tweet_with_media(text, ids).await,
93                _ => client.post_tweet(text).await,
94            }
95        } else {
96            let prev = &posted_ids[i - 1];
97            match tweet_media {
98                Some(ids) if !ids.is_empty() => {
99                    client.reply_to_tweet_with_media(text, prev, ids).await
100                }
101                _ => client.reply_to_tweet(text, prev).await,
102            }
103        };
104
105        match result {
106            Ok(posted) => posted_ids.push(posted.id),
107            Err(e) => {
108                let posted = posted_ids.len();
109                return Err(ToolkitError::ThreadPartialFailure {
110                    posted_ids,
111                    failed_index: i,
112                    posted,
113                    total,
114                    source: Box::new(e),
115                });
116            }
117        }
118    }
119
120    Ok(posted_ids)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::error::XApiError;
127    use crate::x_api::types::*;
128    use std::sync::atomic::{AtomicUsize, Ordering};
129
130    struct MockClient;
131
132    #[async_trait::async_trait]
133    impl XApiClient for MockClient {
134        async fn search_tweets(
135            &self,
136            _: &str,
137            _: u32,
138            _: Option<&str>,
139            _: Option<&str>,
140        ) -> Result<SearchResponse, XApiError> {
141            unimplemented!()
142        }
143        async fn get_mentions(
144            &self,
145            _: &str,
146            _: Option<&str>,
147            _: Option<&str>,
148        ) -> Result<MentionResponse, XApiError> {
149            unimplemented!()
150        }
151        async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
152            Ok(PostedTweet {
153                id: "t1".into(),
154                text: text.into(),
155            })
156        }
157        async fn reply_to_tweet(&self, text: &str, _: &str) -> Result<PostedTweet, XApiError> {
158            Ok(PostedTweet {
159                id: "t2".into(),
160                text: text.into(),
161            })
162        }
163        async fn get_tweet(&self, _: &str) -> Result<Tweet, XApiError> {
164            unimplemented!()
165        }
166        async fn get_me(&self) -> Result<User, XApiError> {
167            unimplemented!()
168        }
169        async fn get_user_tweets(
170            &self,
171            _: &str,
172            _: u32,
173            _: Option<&str>,
174        ) -> Result<SearchResponse, XApiError> {
175            unimplemented!()
176        }
177        async fn get_user_by_username(&self, _: &str) -> Result<User, XApiError> {
178            unimplemented!()
179        }
180    }
181
182    #[tokio::test]
183    async fn post_tweet_success() {
184        let r = post_tweet(&MockClient, "Hello", None).await.unwrap();
185        assert_eq!(r.id, "t1");
186    }
187
188    #[tokio::test]
189    async fn post_tweet_too_long() {
190        let text = "a".repeat(281);
191        let e = post_tweet(&MockClient, &text, None).await.unwrap_err();
192        assert!(matches!(e, ToolkitError::TweetTooLong { .. }));
193    }
194
195    #[tokio::test]
196    async fn reply_to_tweet_success() {
197        let r = reply_to_tweet(&MockClient, "Nice", "t0", None)
198            .await
199            .unwrap();
200        assert_eq!(r.id, "t2");
201    }
202
203    #[tokio::test]
204    async fn reply_to_tweet_empty_id() {
205        let e = reply_to_tweet(&MockClient, "Hi", "", None)
206            .await
207            .unwrap_err();
208        assert!(matches!(e, ToolkitError::InvalidInput { .. }));
209    }
210
211    #[tokio::test]
212    async fn delete_tweet_empty_id() {
213        let e = delete_tweet(&MockClient, "").await.unwrap_err();
214        assert!(matches!(e, ToolkitError::InvalidInput { .. }));
215    }
216
217    #[tokio::test]
218    async fn post_thread_success() {
219        let tweets = vec!["First".into(), "Second".into()];
220        let ids = post_thread(&MockClient, &tweets, None).await.unwrap();
221        assert_eq!(ids.len(), 2);
222    }
223
224    #[tokio::test]
225    async fn post_thread_empty() {
226        let e = post_thread(&MockClient, &[], None).await.unwrap_err();
227        assert!(matches!(e, ToolkitError::InvalidInput { .. }));
228    }
229
230    #[tokio::test]
231    async fn post_thread_tweet_too_long() {
232        let tweets = vec!["ok".into(), "a".repeat(281)];
233        let e = post_thread(&MockClient, &tweets, None).await.unwrap_err();
234        assert!(matches!(e, ToolkitError::InvalidInput { .. }));
235    }
236
237    #[tokio::test]
238    async fn post_thread_partial_failure() {
239        struct PartialClient {
240            calls: AtomicUsize,
241        }
242        #[async_trait::async_trait]
243        impl XApiClient for PartialClient {
244            async fn search_tweets(
245                &self,
246                _: &str,
247                _: u32,
248                _: Option<&str>,
249                _: Option<&str>,
250            ) -> Result<SearchResponse, XApiError> {
251                unimplemented!()
252            }
253            async fn get_mentions(
254                &self,
255                _: &str,
256                _: Option<&str>,
257                _: Option<&str>,
258            ) -> Result<MentionResponse, XApiError> {
259                unimplemented!()
260            }
261            async fn post_tweet(&self, _: &str) -> Result<PostedTweet, XApiError> {
262                let n = self.calls.fetch_add(1, Ordering::SeqCst);
263                Ok(PostedTweet {
264                    id: format!("t{n}"),
265                    text: "ok".into(),
266                })
267            }
268            async fn reply_to_tweet(&self, _: &str, _: &str) -> Result<PostedTweet, XApiError> {
269                Err(XApiError::ApiError {
270                    status: 500,
271                    message: "fail".into(),
272                })
273            }
274            async fn get_tweet(&self, _: &str) -> Result<Tweet, XApiError> {
275                unimplemented!()
276            }
277            async fn get_me(&self) -> Result<User, XApiError> {
278                unimplemented!()
279            }
280            async fn get_user_tweets(
281                &self,
282                _: &str,
283                _: u32,
284                _: Option<&str>,
285            ) -> Result<SearchResponse, XApiError> {
286                unimplemented!()
287            }
288            async fn get_user_by_username(&self, _: &str) -> Result<User, XApiError> {
289                unimplemented!()
290            }
291        }
292
293        let client = PartialClient {
294            calls: AtomicUsize::new(0),
295        };
296        let tweets = vec!["First".into(), "Second".into(), "Third".into()];
297        let e = post_thread(&client, &tweets, None).await.unwrap_err();
298        match e {
299            ToolkitError::ThreadPartialFailure {
300                posted_ids,
301                failed_index,
302                posted,
303                total,
304                ..
305            } => {
306                assert_eq!(posted_ids, vec!["t0"]);
307                assert_eq!(failed_index, 1);
308                assert_eq!(posted, 1);
309                assert_eq!(total, 3);
310            }
311            other => panic!("expected ThreadPartialFailure, got {other:?}"),
312        }
313    }
314}