Skip to main content

xcom_rs/search/
commands.rs

1use super::models::{
2    SearchPaginationMeta, SearchRecentResult, SearchResultMeta, SearchTweet, SearchUser,
3    SearchUsersResult,
4};
5use anyhow::Result;
6
7/// Arguments for recent tweet search
8#[derive(Debug, Clone)]
9pub struct SearchRecentArgs {
10    pub query: String,
11    pub limit: Option<usize>,
12    pub cursor: Option<String>,
13}
14
15/// Arguments for user search
16#[derive(Debug, Clone)]
17pub struct SearchUsersArgs {
18    pub query: String,
19    pub limit: Option<usize>,
20    pub cursor: Option<String>,
21}
22
23/// Trait for X API search client (enables testing via mocking)
24pub trait SearchClient {
25    /// Search recent tweets
26    fn search_recent(&self, args: &SearchRecentArgs) -> Result<SearchRecentResult>;
27    /// Search users
28    fn search_users(&self, args: &SearchUsersArgs) -> Result<SearchUsersResult>;
29}
30
31/// Mock implementation of SearchClient for testing
32pub struct MockSearchClient {
33    pub tweets: Vec<SearchTweet>,
34    pub users: Vec<SearchUser>,
35}
36
37impl MockSearchClient {
38    /// Create a new mock with empty data
39    pub fn new() -> Self {
40        Self {
41            tweets: Vec::new(),
42            users: Vec::new(),
43        }
44    }
45
46    /// Create a mock with sample tweet fixtures
47    pub fn with_tweet_fixtures(count: usize) -> Self {
48        let tweets = (0..count)
49            .map(|i| {
50                let mut tweet = SearchTweet::new(format!("fixture_tweet_{}", i));
51                tweet.text = Some(format!("Fixture tweet text {}", i));
52                tweet.author_id = Some(format!("fixture_user_{}", i));
53                tweet.created_at = Some("2024-01-01T00:00:00Z".to_string());
54                tweet
55            })
56            .collect();
57        Self {
58            tweets,
59            users: Vec::new(),
60        }
61    }
62
63    /// Create a mock with sample user fixtures
64    pub fn with_user_fixtures(count: usize) -> Self {
65        let users = (0..count)
66            .map(|i| {
67                let mut user = SearchUser::new(format!("fixture_user_{}", i));
68                user.name = Some(format!("Fixture User {}", i));
69                user.username = Some(format!("fixture_user_{}", i));
70                user.description = Some(format!("A fixture user {}", i));
71                user
72            })
73            .collect();
74        Self {
75            tweets: Vec::new(),
76            users,
77        }
78    }
79}
80
81impl Default for MockSearchClient {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl SearchClient for MockSearchClient {
88    fn search_recent(&self, args: &SearchRecentArgs) -> Result<SearchRecentResult> {
89        let limit = args.limit.unwrap_or(10);
90        let offset = parse_cursor(&args.cursor);
91        let tweets: Vec<SearchTweet> = self
92            .tweets
93            .iter()
94            .skip(offset)
95            .take(limit)
96            .cloned()
97            .collect();
98        let result_count = tweets.len();
99        let next_token = if result_count == limit && offset + limit < self.tweets.len() {
100            Some(format!("cursor_{}", offset + limit))
101        } else {
102            None
103        };
104        let prev_token = if offset > 0 {
105            Some(format!("cursor_{}", offset.saturating_sub(limit)))
106        } else {
107            None
108        };
109        Ok(SearchRecentResult {
110            tweets,
111            meta: Some(SearchResultMeta {
112                pagination: SearchPaginationMeta {
113                    next_token,
114                    prev_token,
115                    result_count,
116                },
117            }),
118        })
119    }
120
121    fn search_users(&self, args: &SearchUsersArgs) -> Result<SearchUsersResult> {
122        let limit = args.limit.unwrap_or(10);
123        let offset = parse_cursor(&args.cursor);
124        let users: Vec<SearchUser> = self
125            .users
126            .iter()
127            .skip(offset)
128            .take(limit)
129            .cloned()
130            .collect();
131        let result_count = users.len();
132        let next_token = if result_count == limit && offset + limit < self.users.len() {
133            Some(format!("cursor_{}", offset + limit))
134        } else {
135            None
136        };
137        let prev_token = if offset > 0 {
138            Some(format!("cursor_{}", offset.saturating_sub(limit)))
139        } else {
140            None
141        };
142        Ok(SearchUsersResult {
143            users,
144            meta: Some(SearchResultMeta {
145                pagination: SearchPaginationMeta {
146                    next_token,
147                    prev_token,
148                    result_count,
149                },
150            }),
151        })
152    }
153}
154
155/// Search command handler
156pub struct SearchCommand;
157
158impl SearchCommand {
159    pub fn new() -> Self {
160        Self
161    }
162
163    /// Search recent tweets matching the query
164    pub fn search_recent(&self, args: SearchRecentArgs) -> Result<SearchRecentResult> {
165        // Check for simulated errors via environment variables (for testing)
166        if let Ok(error_type) = std::env::var("XCOM_SIMULATE_ERROR") {
167            if error_type == "rate_limit" {
168                return Err(anyhow::anyhow!("Rate limit exceeded"));
169            }
170        }
171
172        let limit = args.limit.unwrap_or(10);
173
174        // Parse cursor to determine starting offset
175        let offset = parse_cursor(&args.cursor);
176
177        // Simulate fetching recent tweets matching query (in real implementation, would call X API)
178        let tweets: Vec<SearchTweet> = (offset..(offset + limit))
179            .map(|i| {
180                let mut tweet = SearchTweet::new(format!("tweet_{}", i));
181                tweet.text = Some(format!("{}: Tweet text {}", args.query, i));
182                tweet.author_id = Some(format!("user_{}", i));
183                tweet.created_at = Some("2024-01-01T00:00:00Z".to_string());
184                tweet
185            })
186            .collect();
187
188        let result_count = tweets.len();
189        let next_token = if result_count == limit {
190            Some(format!("cursor_{}", offset + limit))
191        } else {
192            None
193        };
194        let prev_token = if offset > 0 {
195            Some(format!("cursor_{}", offset.saturating_sub(limit)))
196        } else {
197            None
198        };
199
200        let meta = Some(SearchResultMeta {
201            pagination: SearchPaginationMeta {
202                next_token,
203                prev_token,
204                result_count,
205            },
206        });
207
208        Ok(SearchRecentResult { tweets, meta })
209    }
210
211    /// Search users matching the query
212    pub fn search_users(&self, args: SearchUsersArgs) -> Result<SearchUsersResult> {
213        // Check for simulated errors via environment variables (for testing)
214        if let Ok(error_type) = std::env::var("XCOM_SIMULATE_ERROR") {
215            if error_type == "rate_limit" {
216                return Err(anyhow::anyhow!("Rate limit exceeded"));
217            }
218        }
219
220        let limit = args.limit.unwrap_or(10);
221
222        // Parse cursor to determine starting offset
223        let offset = parse_cursor(&args.cursor);
224
225        // Simulate fetching users matching query (in real implementation, would call X API)
226        let users: Vec<SearchUser> = (offset..(offset + limit))
227            .map(|i| {
228                let mut user = SearchUser::new(format!("user_{}", i));
229                user.name = Some(format!("{} User {}", args.query, i));
230                user.username = Some(format!(
231                    "{}_user_{}",
232                    args.query.to_lowercase().replace(' ', "_"),
233                    i
234                ));
235                user.description = Some(format!("A user matching '{}' query", args.query));
236                user
237            })
238            .collect();
239
240        let result_count = users.len();
241        let next_token = if result_count == limit {
242            Some(format!("cursor_{}", offset + limit))
243        } else {
244            None
245        };
246        let prev_token = if offset > 0 {
247            Some(format!("cursor_{}", offset.saturating_sub(limit)))
248        } else {
249            None
250        };
251
252        let meta = Some(SearchResultMeta {
253            pagination: SearchPaginationMeta {
254                next_token,
255                prev_token,
256                result_count,
257            },
258        });
259
260        Ok(SearchUsersResult { users, meta })
261    }
262}
263
264impl Default for SearchCommand {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270/// Parse cursor token into an offset value
271fn parse_cursor(cursor: &Option<String>) -> usize {
272    if let Some(cursor) = cursor {
273        cursor
274            .strip_prefix("cursor_")
275            .and_then(|s| s.parse::<usize>().ok())
276            .unwrap_or(0)
277    } else {
278        0
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_search_recent_basic() {
288        let cmd = SearchCommand::new();
289        let args = SearchRecentArgs {
290            query: "hello world".to_string(),
291            limit: Some(5),
292            cursor: None,
293        };
294
295        let result = cmd.search_recent(args).unwrap();
296        assert_eq!(result.tweets.len(), 5);
297        assert!(result.meta.is_some());
298        let meta = result.meta.unwrap();
299        assert_eq!(meta.pagination.result_count, 5);
300        assert_eq!(meta.pagination.next_token, Some("cursor_5".to_string()));
301        assert!(meta.pagination.prev_token.is_none());
302    }
303
304    #[test]
305    fn test_search_recent_with_cursor() {
306        let cmd = SearchCommand::new();
307        let args = SearchRecentArgs {
308            query: "rust".to_string(),
309            limit: Some(5),
310            cursor: Some("cursor_10".to_string()),
311        };
312
313        let result = cmd.search_recent(args).unwrap();
314        assert_eq!(result.tweets.len(), 5);
315        let meta = result.meta.unwrap();
316        assert_eq!(meta.pagination.next_token, Some("cursor_15".to_string()));
317        assert_eq!(meta.pagination.prev_token, Some("cursor_5".to_string()));
318    }
319
320    #[test]
321    fn test_search_recent_tweet_contains_query() {
322        let cmd = SearchCommand::new();
323        let args = SearchRecentArgs {
324            query: "rustlang".to_string(),
325            limit: Some(3),
326            cursor: None,
327        };
328
329        let result = cmd.search_recent(args).unwrap();
330        for tweet in &result.tweets {
331            let text = tweet.text.as_ref().unwrap();
332            assert!(
333                text.contains("rustlang"),
334                "Tweet text should contain query: {}",
335                text
336            );
337        }
338    }
339
340    #[test]
341    fn test_search_recent_default_limit() {
342        let cmd = SearchCommand::new();
343        let args = SearchRecentArgs {
344            query: "test".to_string(),
345            limit: None,
346            cursor: None,
347        };
348
349        let result = cmd.search_recent(args).unwrap();
350        assert_eq!(result.tweets.len(), 10); // default limit
351    }
352
353    #[test]
354    fn test_search_users_basic() {
355        let cmd = SearchCommand::new();
356        let args = SearchUsersArgs {
357            query: "alice".to_string(),
358            limit: Some(5),
359            cursor: None,
360        };
361
362        let result = cmd.search_users(args).unwrap();
363        assert_eq!(result.users.len(), 5);
364        assert!(result.meta.is_some());
365        let meta = result.meta.unwrap();
366        assert_eq!(meta.pagination.result_count, 5);
367        assert_eq!(meta.pagination.next_token, Some("cursor_5".to_string()));
368    }
369
370    #[test]
371    fn test_search_users_with_cursor() {
372        let cmd = SearchCommand::new();
373        let args = SearchUsersArgs {
374            query: "bob".to_string(),
375            limit: Some(5),
376            cursor: Some("cursor_5".to_string()),
377        };
378
379        let result = cmd.search_users(args).unwrap();
380        assert_eq!(result.users.len(), 5);
381        let meta = result.meta.unwrap();
382        assert_eq!(meta.pagination.next_token, Some("cursor_10".to_string()));
383        assert_eq!(meta.pagination.prev_token, Some("cursor_0".to_string()));
384    }
385
386    #[test]
387    fn test_search_users_user_fields() {
388        let cmd = SearchCommand::new();
389        let args = SearchUsersArgs {
390            query: "developer".to_string(),
391            limit: Some(3),
392            cursor: None,
393        };
394
395        let result = cmd.search_users(args).unwrap();
396        for user in &result.users {
397            assert!(!user.id.is_empty());
398            assert!(user.name.is_some());
399            assert!(user.username.is_some());
400            assert!(user.description.is_some());
401        }
402    }
403
404    #[test]
405    fn test_search_users_default_limit() {
406        let cmd = SearchCommand::new();
407        let args = SearchUsersArgs {
408            query: "test".to_string(),
409            limit: None,
410            cursor: None,
411        };
412
413        let result = cmd.search_users(args).unwrap();
414        assert_eq!(result.users.len(), 10); // default limit
415    }
416
417    #[test]
418    fn test_parse_cursor_none() {
419        assert_eq!(parse_cursor(&None), 0);
420    }
421
422    #[test]
423    fn test_parse_cursor_valid() {
424        assert_eq!(parse_cursor(&Some("cursor_42".to_string())), 42);
425    }
426
427    #[test]
428    fn test_parse_cursor_invalid() {
429        assert_eq!(parse_cursor(&Some("invalid".to_string())), 0);
430    }
431}