Skip to main content

tuitbot_core/toolkit/
read.rs

1//! Stateless read operations over `&dyn XApiClient`.
2//!
3//! Each function validates inputs, delegates to the client trait,
4//! and maps errors to `ToolkitError`. No state, no DB, no policy.
5
6use super::ToolkitError;
7use crate::x_api::types::{MentionResponse, SearchResponse, Tweet, User, UsersResponse};
8use crate::x_api::XApiClient;
9
10/// Get a single tweet by ID.
11pub async fn get_tweet(client: &dyn XApiClient, tweet_id: &str) -> Result<Tweet, ToolkitError> {
12    super::validate_id(tweet_id, "tweet_id")?;
13    Ok(client.get_tweet(tweet_id).await?)
14}
15
16/// Look up a user by username.
17pub async fn get_user_by_username(
18    client: &dyn XApiClient,
19    username: &str,
20) -> Result<User, ToolkitError> {
21    super::validate_id(username, "username")?;
22    Ok(client.get_user_by_username(username).await?)
23}
24
25/// Get a user by their numeric ID.
26pub async fn get_user_by_id(client: &dyn XApiClient, user_id: &str) -> Result<User, ToolkitError> {
27    super::validate_id(user_id, "user_id")?;
28    Ok(client.get_user_by_id(user_id).await?)
29}
30
31/// Get the authenticated user's profile.
32pub async fn get_me(client: &dyn XApiClient) -> Result<User, ToolkitError> {
33    Ok(client.get_me().await?)
34}
35
36/// Search recent tweets matching a query.
37pub async fn search_tweets(
38    client: &dyn XApiClient,
39    query: &str,
40    max_results: u32,
41    since_id: Option<&str>,
42    pagination_token: Option<&str>,
43) -> Result<SearchResponse, ToolkitError> {
44    if query.is_empty() {
45        return Err(ToolkitError::InvalidInput {
46            message: "query must not be empty".into(),
47        });
48    }
49    Ok(client
50        .search_tweets(query, max_results, since_id, pagination_token)
51        .await?)
52}
53
54/// Get mentions for a user.
55pub async fn get_mentions(
56    client: &dyn XApiClient,
57    user_id: &str,
58    since_id: Option<&str>,
59    pagination_token: Option<&str>,
60) -> Result<MentionResponse, ToolkitError> {
61    super::validate_id(user_id, "user_id")?;
62    Ok(client
63        .get_mentions(user_id, since_id, pagination_token)
64        .await?)
65}
66
67/// Get recent tweets from a specific user.
68pub async fn get_user_tweets(
69    client: &dyn XApiClient,
70    user_id: &str,
71    max_results: u32,
72    pagination_token: Option<&str>,
73) -> Result<SearchResponse, ToolkitError> {
74    super::validate_id(user_id, "user_id")?;
75    Ok(client
76        .get_user_tweets(user_id, max_results, pagination_token)
77        .await?)
78}
79
80/// Get the authenticated user's home timeline.
81pub async fn get_home_timeline(
82    client: &dyn XApiClient,
83    user_id: &str,
84    max_results: u32,
85    pagination_token: Option<&str>,
86) -> Result<SearchResponse, ToolkitError> {
87    super::validate_id(user_id, "user_id")?;
88    Ok(client
89        .get_home_timeline(user_id, max_results, pagination_token)
90        .await?)
91}
92
93/// Get followers of a user.
94pub async fn get_followers(
95    client: &dyn XApiClient,
96    user_id: &str,
97    max_results: u32,
98    pagination_token: Option<&str>,
99) -> Result<UsersResponse, ToolkitError> {
100    super::validate_id(user_id, "user_id")?;
101    Ok(client
102        .get_followers(user_id, max_results, pagination_token)
103        .await?)
104}
105
106/// Get accounts a user is following.
107pub async fn get_following(
108    client: &dyn XApiClient,
109    user_id: &str,
110    max_results: u32,
111    pagination_token: Option<&str>,
112) -> Result<UsersResponse, ToolkitError> {
113    super::validate_id(user_id, "user_id")?;
114    Ok(client
115        .get_following(user_id, max_results, pagination_token)
116        .await?)
117}
118
119/// Get tweets liked by a user.
120pub async fn get_liked_tweets(
121    client: &dyn XApiClient,
122    user_id: &str,
123    max_results: u32,
124    pagination_token: Option<&str>,
125) -> Result<SearchResponse, ToolkitError> {
126    super::validate_id(user_id, "user_id")?;
127    Ok(client
128        .get_liked_tweets(user_id, max_results, pagination_token)
129        .await?)
130}
131
132/// Get the authenticated user's bookmarks.
133pub async fn get_bookmarks(
134    client: &dyn XApiClient,
135    user_id: &str,
136    max_results: u32,
137    pagination_token: Option<&str>,
138) -> Result<SearchResponse, ToolkitError> {
139    super::validate_id(user_id, "user_id")?;
140    Ok(client
141        .get_bookmarks(user_id, max_results, pagination_token)
142        .await?)
143}
144
145/// Get multiple users by their IDs (1-100).
146pub async fn get_users_by_ids(
147    client: &dyn XApiClient,
148    user_ids: &[&str],
149) -> Result<UsersResponse, ToolkitError> {
150    if user_ids.is_empty() || user_ids.len() > 100 {
151        return Err(ToolkitError::InvalidInput {
152            message: format!("user_ids must contain 1-100 IDs, got {}", user_ids.len()),
153        });
154    }
155    Ok(client.get_users_by_ids(user_ids).await?)
156}
157
158/// Get users who liked a specific tweet.
159pub async fn get_tweet_liking_users(
160    client: &dyn XApiClient,
161    tweet_id: &str,
162    max_results: u32,
163    pagination_token: Option<&str>,
164) -> Result<UsersResponse, ToolkitError> {
165    super::validate_id(tweet_id, "tweet_id")?;
166    Ok(client
167        .get_tweet_liking_users(tweet_id, max_results, pagination_token)
168        .await?)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::error::XApiError;
175    use crate::x_api::types::*;
176
177    struct MockClient;
178
179    #[async_trait::async_trait]
180    impl XApiClient for MockClient {
181        async fn search_tweets(
182            &self,
183            _: &str,
184            _: u32,
185            _: Option<&str>,
186            _: Option<&str>,
187        ) -> Result<SearchResponse, XApiError> {
188            Ok(empty_search())
189        }
190        async fn get_mentions(
191            &self,
192            _: &str,
193            _: Option<&str>,
194            _: Option<&str>,
195        ) -> Result<MentionResponse, XApiError> {
196            Ok(empty_search())
197        }
198        async fn post_tweet(&self, _: &str) -> Result<PostedTweet, XApiError> {
199            Ok(PostedTweet {
200                id: "t1".into(),
201                text: "t".into(),
202            })
203        }
204        async fn reply_to_tweet(&self, _: &str, _: &str) -> Result<PostedTweet, XApiError> {
205            Ok(PostedTweet {
206                id: "t2".into(),
207                text: "t".into(),
208            })
209        }
210        async fn get_tweet(&self, id: &str) -> Result<Tweet, XApiError> {
211            Ok(Tweet {
212                id: id.to_string(),
213                text: "hello".into(),
214                author_id: "a1".into(),
215                created_at: String::new(),
216                public_metrics: PublicMetrics::default(),
217                conversation_id: None,
218            })
219        }
220        async fn get_me(&self) -> Result<User, XApiError> {
221            Ok(test_user("me"))
222        }
223        async fn get_user_tweets(
224            &self,
225            _: &str,
226            _: u32,
227            _: Option<&str>,
228        ) -> Result<SearchResponse, XApiError> {
229            Ok(empty_search())
230        }
231        async fn get_user_by_username(&self, u: &str) -> Result<User, XApiError> {
232            Ok(test_user(u))
233        }
234    }
235
236    fn test_user(id: &str) -> User {
237        User {
238            id: id.into(),
239            username: id.into(),
240            name: "Test".into(),
241            profile_image_url: None,
242            public_metrics: UserMetrics::default(),
243        }
244    }
245
246    fn empty_search() -> SearchResponse {
247        SearchResponse {
248            data: vec![],
249            includes: None,
250            meta: SearchMeta {
251                newest_id: None,
252                oldest_id: None,
253                result_count: 0,
254                next_token: None,
255            },
256        }
257    }
258
259    #[tokio::test]
260    async fn get_tweet_success() {
261        let t = get_tweet(&MockClient, "123").await.unwrap();
262        assert_eq!(t.id, "123");
263    }
264
265    #[tokio::test]
266    async fn get_tweet_empty_id() {
267        let e = get_tweet(&MockClient, "").await.unwrap_err();
268        assert!(matches!(e, ToolkitError::InvalidInput { .. }));
269    }
270
271    #[tokio::test]
272    async fn get_user_by_username_success() {
273        let u = get_user_by_username(&MockClient, "alice").await.unwrap();
274        assert_eq!(u.username, "alice");
275    }
276
277    #[tokio::test]
278    async fn get_user_by_username_empty() {
279        let e = get_user_by_username(&MockClient, "").await.unwrap_err();
280        assert!(matches!(e, ToolkitError::InvalidInput { .. }));
281    }
282
283    #[tokio::test]
284    async fn search_tweets_success() {
285        let r = search_tweets(&MockClient, "rust", 10, None, None)
286            .await
287            .unwrap();
288        assert_eq!(r.meta.result_count, 0);
289    }
290
291    #[tokio::test]
292    async fn search_tweets_empty_query() {
293        let e = search_tweets(&MockClient, "", 10, None, None)
294            .await
295            .unwrap_err();
296        assert!(matches!(e, ToolkitError::InvalidInput { .. }));
297    }
298
299    #[tokio::test]
300    async fn get_me_success() {
301        let u = get_me(&MockClient).await.unwrap();
302        assert_eq!(u.id, "me");
303    }
304
305    #[tokio::test]
306    async fn get_users_by_ids_empty() {
307        let e = get_users_by_ids(&MockClient, &[]).await.unwrap_err();
308        assert!(matches!(e, ToolkitError::InvalidInput { .. }));
309    }
310
311    #[tokio::test]
312    async fn get_users_by_ids_over_100() {
313        let ids: Vec<&str> = (0..101).map(|_| "x").collect();
314        let e = get_users_by_ids(&MockClient, &ids).await.unwrap_err();
315        assert!(matches!(e, ToolkitError::InvalidInput { .. }));
316    }
317
318    #[tokio::test]
319    async fn x_api_error_maps_to_toolkit_error() {
320        struct FailClient;
321        #[async_trait::async_trait]
322        impl XApiClient for FailClient {
323            async fn search_tweets(
324                &self,
325                _: &str,
326                _: u32,
327                _: Option<&str>,
328                _: Option<&str>,
329            ) -> Result<SearchResponse, XApiError> {
330                Err(XApiError::RateLimited {
331                    retry_after: Some(30),
332                })
333            }
334            async fn get_mentions(
335                &self,
336                _: &str,
337                _: Option<&str>,
338                _: Option<&str>,
339            ) -> Result<MentionResponse, XApiError> {
340                Err(XApiError::AuthExpired)
341            }
342            async fn post_tweet(&self, _: &str) -> Result<PostedTweet, XApiError> {
343                Err(XApiError::AuthExpired)
344            }
345            async fn reply_to_tweet(&self, _: &str, _: &str) -> Result<PostedTweet, XApiError> {
346                Err(XApiError::AuthExpired)
347            }
348            async fn get_tweet(&self, _: &str) -> Result<Tweet, XApiError> {
349                Err(XApiError::ApiError {
350                    status: 404,
351                    message: "Not found".into(),
352                })
353            }
354            async fn get_me(&self) -> Result<User, XApiError> {
355                Err(XApiError::AuthExpired)
356            }
357            async fn get_user_tweets(
358                &self,
359                _: &str,
360                _: u32,
361                _: Option<&str>,
362            ) -> Result<SearchResponse, XApiError> {
363                Err(XApiError::AuthExpired)
364            }
365            async fn get_user_by_username(&self, _: &str) -> Result<User, XApiError> {
366                Err(XApiError::AuthExpired)
367            }
368        }
369
370        let e = get_tweet(&FailClient, "123").await.unwrap_err();
371        assert!(matches!(
372            e,
373            ToolkitError::XApi(XApiError::ApiError { status: 404, .. })
374        ));
375
376        let e = search_tweets(&FailClient, "q", 10, None, None)
377            .await
378            .unwrap_err();
379        assert!(matches!(
380            e,
381            ToolkitError::XApi(XApiError::RateLimited { .. })
382        ));
383    }
384}